Formatting and cleanup
This commit is contained in:
59
.env.example
59
.env.example
@@ -1,40 +1,67 @@
|
||||
# -----------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------
|
||||
# Rust log filter directive (e.g. warn,siren=info)
|
||||
RUST_LOG=warn,siren=info
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Discord
|
||||
# -----------------------------------------------------------
|
||||
# Bot token from the Discord Developer Portal → Bot tab → Reset Token
|
||||
DISCORD_BOT_TOKEN=
|
||||
# OAuth2 client secret from the Discord Developer Portal → OAuth2 tab
|
||||
DISCORD_CLIENT_SECRET=
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Security
|
||||
# -----------------------------------------------------------
|
||||
# Secret used to sign JWT tokens — change this before deploying
|
||||
JWT_SECRET=changeme
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Database
|
||||
# -----------------------------------------------------------
|
||||
POSTGRES_USER=siren
|
||||
POSTGRES_PASSWORD=changeme
|
||||
POSTGRES_DB=siren_db
|
||||
# Use "siren-postgres" when running inside Docker Compose
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Cache (Valkey)
|
||||
# -----------------------------------------------------------
|
||||
# Use "siren-valkey" when running inside Docker Compose
|
||||
VALKEY_HOST=localhost
|
||||
VALKEY_PORT=6379
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# API
|
||||
# -----------------------------------------------------------
|
||||
# Base URL of the REST API (used to build OAuth2 redirect URIs, etc.)
|
||||
API_BASE_URL=http://localhost:3000
|
||||
API_PORT=3000
|
||||
# OAuth2 session TTL in seconds
|
||||
API_SESSION_TTL=86400
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# UI
|
||||
# -----------------------------------------------------------
|
||||
# Set to a specific origin (e.g. https://yourapp.com) when deploying to
|
||||
# production with a separate frontend origin. Use "*" (the default) in
|
||||
# development with the Vite proxy, where CORS is not an issue.
|
||||
CORS_ORIGIN=*
|
||||
# Port the UI dev server (Vite) listens on
|
||||
UI_PORT=5173
|
||||
|
||||
UI_PORT=8080
|
||||
# -----------------------------------------------------------
|
||||
# Bot
|
||||
# -----------------------------------------------------------
|
||||
# Re-register slash commands with Discord on every startup
|
||||
FORCE_COMMAND_REGISTER=false
|
||||
|
||||
VALKEY_HOST=localhost
|
||||
VALKEY_PORT=6379
|
||||
|
||||
MINIO_ROOT_USER=siren
|
||||
MINIO_ROOT_PASSWORD=changeme
|
||||
MINIO_HOST=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_PORT_INTERNAL=9001
|
||||
|
||||
# Siren Data integration (Optional)
|
||||
# -----------------------------------------------------------
|
||||
# Data (Optional)
|
||||
# -----------------------------------------------------------
|
||||
# Path to a local directory for optional Siren Data integration
|
||||
DATA_DIR_PATH=./data
|
||||
|
||||
FORCE_REGISTER=false
|
||||
DEFAULT_API_KEY=test_api_key
|
||||
DEFAULT_SERVER=
|
||||
DEFAULT_USER=
|
||||
|
||||
20
README.md
20
README.md
@@ -52,7 +52,7 @@ task setup
|
||||
### Environment variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|-------------------------|----------|-------------------------------------------------------------------------|
|
||||
|--------------------------|----------|---------------------------------------------------------------------------|
|
||||
| `DISCORD_BOT_TOKEN` | Yes | Bot token from the Discord Developer Portal |
|
||||
| `DISCORD_CLIENT_SECRET` | Yes | OAuth2 client secret |
|
||||
| `JWT_SECRET` | Yes | Secret used to sign JWT tokens — change from default |
|
||||
@@ -60,18 +60,20 @@ task setup
|
||||
| `POSTGRES_PASSWORD` | Yes | PostgreSQL password — change from default |
|
||||
| `POSTGRES_DB` | Yes | PostgreSQL database name |
|
||||
| `POSTGRES_HOST` | Yes | PostgreSQL host (`localhost` for local dev, `siren-postgres` in Docker) |
|
||||
| `POSTGRES_PORT` | Yes | PostgreSQL port (default `5432`) |
|
||||
| `VALKEY_HOST` | Yes | Valkey host (`localhost` for local dev, `siren-valkey` in Docker) |
|
||||
| `VALKEY_PORT` | Yes | Valkey port (default `6379`) |
|
||||
| `API_PORT` | Yes | Port the REST API listens on (default `3000`) |
|
||||
| `API_CALLBACK_URI` | Yes | OAuth2 redirect URI (e.g. `http://localhost:3000/api/oauth/callback`) |
|
||||
| `POSTGRES_PORT` | | PostgreSQL port (default `5432`) |
|
||||
| `VALKEY_HOST` | | Valkey host (`localhost` for local dev, `siren-valkey` in Docker) |
|
||||
| `VALKEY_PORT` | | Valkey port (default `6379`) |
|
||||
| `API_BASE_URL` | Yes | Base URL of the API (e.g. `http://localhost:3000`) |
|
||||
| `API_PORT` | | Port the REST API listens on (default `3000`) |
|
||||
| `API_SESSION_TTL` | | OAuth2 session TTL in seconds (default `86400`) |
|
||||
| `CORS_ORIGIN` | | Allowed CORS origin (`*` for dev, specific URL for production) |
|
||||
| `UI_PORT` | | Port the UI dev server listens on (default `5173`) |
|
||||
| `RUST_LOG` | | Log filter (e.g. `warn,siren=info`) |
|
||||
| `FORCE_REGISTER` | | Re-register slash commands on every startup (`true`/`false`) |
|
||||
| `FORCE_COMMAND_REGISTER` | | Re-register slash commands with Discord on every startup (`true`/`false`) |
|
||||
| `DATA_DIR_PATH` | | Path to optional local data directory |
|
||||
| `DEFAULT_API_KEY` | | Seed API key created on startup |
|
||||
| `DEFAULT_SERVER` | | Seed guild ID |
|
||||
| `DEFAULT_USER` | | Seed user ID |
|
||||
| `DEFAULT_GUILD_ID` | | Seed Discord guild (server) ID |
|
||||
| `DEFAULT_USER_ID` | | Seed Discord user ID |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ tasks:
|
||||
ngrok:
|
||||
desc: Start ngrok tunnel
|
||||
vars:
|
||||
UI_PORT: '{{.UI_PORT | default "8080"}}'
|
||||
UI_PORT: '{{.UI_PORT | default "5173"}}'
|
||||
cmds:
|
||||
- ngrok http {{.UI_PORT}}
|
||||
silent: true
|
||||
|
||||
@@ -292,9 +292,6 @@ async fn do_oauth_callback(
|
||||
Ok(Redirect::temporary(&ui_redirect_uri).into_response())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// LOGIN MODE: look up (or create) the local user for this Discord account
|
||||
// ------------------------------------------------------------------ //
|
||||
None => {
|
||||
// Find existing connection → local user_id
|
||||
let local_user_id: Option<(Uuid, String)> = sqlx::query_as(
|
||||
@@ -403,10 +400,6 @@ async fn do_oauth_callback(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Return a username derived from `base` that does not yet exist in `users`.
|
||||
async fn generate_unique_username(pool: &sqlx::PgPool, base: &str) -> crate::error::Result<String> {
|
||||
// Truncate to 28 chars to leave room for the `_XXXX` suffix
|
||||
|
||||
@@ -41,10 +41,6 @@ pub fn get_routes() -> Router<Arc<AppState>> {
|
||||
.route("/connections/{provider}", delete(disconnect_provider))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegisterPayload {
|
||||
username: String,
|
||||
@@ -71,10 +67,6 @@ struct ChangePasswordPayload {
|
||||
new_password: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ConnectionInfo {
|
||||
pub provider: String,
|
||||
@@ -95,10 +87,6 @@ pub struct UserInfo {
|
||||
pub connections: Vec<ConnectionInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB row types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DbUser {
|
||||
id: Uuid,
|
||||
@@ -116,10 +104,6 @@ struct DbConnection {
|
||||
provider_avatar: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Password helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Hash and salt a plaintext password with Argon2.
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
@@ -139,10 +123,6 @@ pub fn verify_password(password: &str, hash: &str) -> bool {
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cookie / session helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the `siren_session` HttpOnly Secure cookie.
|
||||
pub fn build_session_cookie(token: String) -> Cookie<'static> {
|
||||
Cookie::build(("siren_session", token))
|
||||
@@ -192,10 +172,6 @@ pub async fn create_session_and_cookie(
|
||||
Ok((jar, ()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: load full UserInfo for a given user_id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn load_user_info(user_id: Uuid) -> Result<UserInfo> {
|
||||
let pool = data::pool();
|
||||
|
||||
@@ -232,10 +208,6 @@ async fn load_user_info(user_id: Uuid) -> Result<UserInfo> {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn register(
|
||||
headers: HeaderMap,
|
||||
jar: CookieJar,
|
||||
@@ -245,12 +217,6 @@ async fn register(
|
||||
if username.is_empty() || username.len() > 32 {
|
||||
return Err(Error::new(422, "Username must be 1–32 characters".into()));
|
||||
}
|
||||
if payload.password.len() < 8 {
|
||||
return Err(Error::new(
|
||||
422,
|
||||
"Password must be at least 8 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let pool = data::pool();
|
||||
|
||||
@@ -402,13 +368,6 @@ async fn change_password(
|
||||
) -> Result<StatusCode> {
|
||||
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||
|
||||
if payload.new_password.len() < 8 {
|
||||
return Err(Error::new(
|
||||
422,
|
||||
"New password must be at least 8 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let pool = data::pool();
|
||||
|
||||
let existing_hash: Option<String> =
|
||||
|
||||
@@ -61,10 +61,6 @@ pub fn get_routes() -> Router<Arc<AppState>> {
|
||||
.route("/maps/{id}/ws", get(ws_handler))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Access helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetch the role of `user_id` on `map_id`, or `None` if no record exists.
|
||||
async fn get_user_role(map_id: &str, user_id: Uuid) -> Result<Option<MapRole>> {
|
||||
let pool = siren_core::data::pool();
|
||||
@@ -120,10 +116,6 @@ async fn is_owner(map: &GridMap, session: &Option<Session>) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_maps(
|
||||
SessionAuthorization(session): SessionAuthorization,
|
||||
) -> Result<Json<Vec<ListedMap>>> {
|
||||
@@ -287,10 +279,6 @@ pub async fn delete_map(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permission management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_permissions(
|
||||
SessionAuthorization(session): SessionAuthorization,
|
||||
Path(id): Path<String>,
|
||||
@@ -429,10 +417,6 @@ pub async fn unfavorite_map(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Access Requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn create_access_request(
|
||||
SessionAuthorization(session): SessionAuthorization,
|
||||
Path(id): Path<String>,
|
||||
@@ -572,10 +556,6 @@ pub async fn resolve_access_request(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<AppState>>,
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map Role / Permission
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize, Deserialize, sqlx::Type, Clone, Debug, PartialEq, Eq)]
|
||||
#[sqlx(type_name = "text", rename_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -36,10 +32,6 @@ pub struct PermissionWithUser {
|
||||
pub role: MapRole,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid Map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Core map record as stored/returned by create, get, and update endpoints.
|
||||
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
|
||||
pub struct GridMap {
|
||||
@@ -49,8 +41,8 @@ pub struct GridMap {
|
||||
pub public_access: String,
|
||||
pub owner_id: Uuid,
|
||||
pub colors: Vec<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Extended map record returned by the list endpoint.
|
||||
@@ -64,8 +56,8 @@ pub struct ListedMap {
|
||||
pub owner_id: Uuid,
|
||||
pub owner_username: String,
|
||||
pub colors: Vec<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// The authenticated caller's role on this map, or NULL if they only have it
|
||||
/// via a favorite (no explicit permission).
|
||||
pub user_role: Option<MapRole>,
|
||||
@@ -98,10 +90,6 @@ pub struct UpdatePermissionPayload {
|
||||
pub role: Option<MapRole>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map Access Requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// An access-request row joined with the requesting user's username.
|
||||
#[derive(Serialize, sqlx::FromRow, Clone, Debug)]
|
||||
pub struct AccessRequestWithUser {
|
||||
@@ -111,8 +99,8 @@ pub struct AccessRequestWithUser {
|
||||
pub username: String,
|
||||
pub requested_role: MapRole,
|
||||
pub status: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
@@ -126,10 +114,6 @@ pub struct ResolveAccessRequestPayload {
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid Cell (no id column — composite PK in DB)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
|
||||
pub struct GridCell {
|
||||
pub map_id: String,
|
||||
@@ -146,10 +130,6 @@ pub struct CellPatch {
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid Token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize, Deserialize, sqlx::FromRow, Clone, Debug)]
|
||||
pub struct GridToken {
|
||||
pub id: String,
|
||||
@@ -160,10 +140,6 @@ pub struct GridToken {
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full map state (used on initial WS connect and REST GET)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct MapState {
|
||||
pub map: GridMap,
|
||||
@@ -171,10 +147,6 @@ pub struct MapState {
|
||||
pub tokens: Vec<GridToken>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket message types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientMessage {
|
||||
|
||||
@@ -16,16 +16,8 @@ pub struct EnvironmentConfiguration {
|
||||
pub api_session_ttl: u64,
|
||||
pub valkey_host: String,
|
||||
pub valkey_port: u16,
|
||||
pub minio_root_user: String,
|
||||
pub minio_root_password: String,
|
||||
pub minio_host: String,
|
||||
pub minio_port: u16,
|
||||
pub minio_port_internal: u16,
|
||||
pub data_dir_path: Option<String>,
|
||||
pub force_register: bool,
|
||||
pub default_api_key: String,
|
||||
pub default_server: Option<String>,
|
||||
pub default_user: Option<String>,
|
||||
pub force_command_register: bool,
|
||||
}
|
||||
|
||||
impl EnvironmentConfiguration {
|
||||
@@ -57,25 +49,11 @@ impl EnvironmentConfiguration {
|
||||
.unwrap_or_else(|_| "6379".to_string())
|
||||
.parse()
|
||||
.unwrap_or(6379),
|
||||
minio_root_user: env::var("MINIO_ROOT_USER")?,
|
||||
minio_root_password: env::var("MINIO_ROOT_PASSWORD")?,
|
||||
minio_host: env::var("MINIO_HOST").unwrap_or_else(|_| "localhost".to_string()),
|
||||
minio_port: env::var("MINIO_PORT")
|
||||
.unwrap_or_else(|_| "9000".to_string())
|
||||
.parse()
|
||||
.unwrap_or(9000),
|
||||
minio_port_internal: env::var("MINIO_PORT_INTERNAL")
|
||||
.unwrap_or_else(|_| "9001".to_string())
|
||||
.parse()
|
||||
.unwrap_or(9001),
|
||||
data_dir_path: env::var("DATA_DIR_PATH").ok().filter(|s| !s.is_empty()),
|
||||
force_register: env::var("FORCE_REGISTER")
|
||||
force_command_register: env::var("FORCE_COMMAND_REGISTER")
|
||||
.ok()
|
||||
.map(|v| v.to_lowercase() == "true")
|
||||
.unwrap_or(false),
|
||||
default_api_key: env::var("DEFAULT_API_KEY").unwrap_or_default(),
|
||||
default_server: env::var("DEFAULT_SERVER").ok().filter(|s| !s.is_empty()),
|
||||
default_user: env::var("DEFAULT_USER").ok().filter(|s| !s.is_empty()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
mod model;
|
||||
|
||||
pub use model::*;
|
||||
@@ -1,74 +0,0 @@
|
||||
use crate::error::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const TABLE_NAME: &str = "messages";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageCache {
|
||||
pub id: String,
|
||||
pub guild_id: i64,
|
||||
pub channel_id: i64,
|
||||
pub author_id: i64,
|
||||
pub created: i64,
|
||||
pub model: String,
|
||||
pub request: String,
|
||||
pub response: String,
|
||||
pub request_tags: Vec<String>,
|
||||
pub response_tags: Vec<String>,
|
||||
}
|
||||
|
||||
impl MessageCache {
|
||||
pub async fn insert(&self) -> Result<()> {
|
||||
let pool = crate::data::pool();
|
||||
sqlx::query(&format!(
|
||||
"INSERT INTO {} (
|
||||
id,
|
||||
guild_id,
|
||||
channel_id,
|
||||
author_id,
|
||||
created,
|
||||
model,
|
||||
request,
|
||||
response,
|
||||
request_tags,
|
||||
response_tags
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
||||
)",
|
||||
TABLE_NAME
|
||||
))
|
||||
.bind(&self.id)
|
||||
.bind(self.guild_id)
|
||||
.bind(self.channel_id)
|
||||
.bind(self.author_id)
|
||||
.bind(self.created)
|
||||
.bind(&self.model)
|
||||
.bind(&self.request)
|
||||
.bind(&self.response)
|
||||
.bind(&self.request_tags)
|
||||
.bind(&self.response_tags)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find(
|
||||
guild_id: i64,
|
||||
channel_id: i64,
|
||||
author_id: i64,
|
||||
limit: i64,
|
||||
) -> Result<Vec<MessageCache>> {
|
||||
let pool = crate::data::pool();
|
||||
let messages = sqlx::query_as::<_, MessageCache>(&format!(
|
||||
"SELECT * FROM {} WHERE guild_id = $1 AND channel_id = $2 AND author_id = $3 ORDER BY created ASC LIMIT $4",
|
||||
TABLE_NAME
|
||||
))
|
||||
.bind(guild_id)
|
||||
.bind(channel_id)
|
||||
.bind(author_id)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(messages)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ pub mod events;
|
||||
mod executable_query;
|
||||
pub mod guilds;
|
||||
pub mod insert;
|
||||
pub mod messages;
|
||||
pub mod query;
|
||||
pub mod update;
|
||||
use crate::config::EnvironmentConfiguration;
|
||||
|
||||
@@ -21,7 +21,7 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||
let config = EnvironmentConfiguration::load()?;
|
||||
siren_core::data::initialize(&config).await?;
|
||||
|
||||
let handler = BotHandler::new(config.force_register);
|
||||
let handler = BotHandler::new(config.force_command_register);
|
||||
let songbird = Songbird::serenity();
|
||||
let intents: GatewayIntents = GatewayIntents::all();
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ CREATE TABLE IF NOT EXISTS events (
|
||||
guild_id BIGINT NOT NULL,
|
||||
author_id BIGINT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
date_time TIMESTAMP NOT NULL,
|
||||
date_time TIMESTAMPTZ NOT NULL,
|
||||
description TEXT,
|
||||
rsvp BIGINT[] NOT NULL
|
||||
);
|
||||
@@ -78,8 +78,8 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
email TEXT UNIQUE,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- External OAuth provider connections (Discord, etc.)
|
||||
@@ -93,14 +93,6 @@ CREATE TABLE IF NOT EXISTS user_connections (
|
||||
UNIQUE (provider, provider_user_id)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- Grid maps: unbounded canvas, CSPRNG TEXT ids, auth-aware
|
||||
-- ============================================================
|
||||
|
||||
-- public_access: 'private' | 'public_view' | 'public_edit'
|
||||
-- private – only users with explicit map_permissions can see/edit
|
||||
-- public_view – anyone with the link can view; only permissioned users can edit
|
||||
-- public_edit – anyone with the link can view AND edit
|
||||
CREATE TABLE IF NOT EXISTS grid_maps (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
@@ -118,8 +110,8 @@ CREATE TABLE IF NOT EXISTS grid_maps (
|
||||
'#0f172a',
|
||||
'#f9fafb'
|
||||
],
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Per-map role assignments; owner is auto-inserted on map creation
|
||||
@@ -135,7 +127,7 @@ CREATE TABLE IF NOT EXISTS map_permissions (
|
||||
CREATE TABLE IF NOT EXISTS map_favorites (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
map_id TEXT NOT NULL REFERENCES grid_maps(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, map_id)
|
||||
);
|
||||
|
||||
@@ -146,8 +138,8 @@ CREATE TABLE IF NOT EXISTS map_access_requests (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
requested_role TEXT NOT NULL CHECK (requested_role IN ('editor', 'viewer')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied')),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (map_id, user_id)
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { auth } from "../api";
|
||||
import type { UserInfo } from "../types";
|
||||
import "./AccountPanel.css";
|
||||
import { FaDiscord } from "react-icons/fa6";
|
||||
|
||||
interface Props {
|
||||
user: UserInfo;
|
||||
@@ -63,7 +64,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
async function handleChangePassword(e: React.SubmitEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setPwError(null);
|
||||
setPwSuccess(false);
|
||||
@@ -72,10 +73,6 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
||||
setPwError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (pwNew.length < 8) {
|
||||
setPwError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setPwSaving(true);
|
||||
try {
|
||||
@@ -296,13 +293,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
||||
<h3>Connected Accounts</h3>
|
||||
|
||||
<div className="account-connection">
|
||||
<svg
|
||||
className="connection-icon discord-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
|
||||
</svg>
|
||||
<FaDiscord />
|
||||
|
||||
<div className="connection-info">
|
||||
<span className="connection-name">Discord</span>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function FloatingMapControls({
|
||||
return (
|
||||
<div className="floating-map-controls">
|
||||
{/* Always visible for logged-in users */}
|
||||
<button className="fmc-btn" onClick={onViewMaps} title="View my maps">
|
||||
<button className="fmc-btn" onClick={onViewMaps} title="View maps">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
|
||||
@@ -17,10 +17,6 @@ import { useWebSocket } from "../hooks/useWebSocket";
|
||||
import TokenDialog from "./TokenDialog";
|
||||
import "./Grid.css";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_ZOOM = 40;
|
||||
const MIN_ZOOM = 8;
|
||||
const MAX_ZOOM = 160;
|
||||
@@ -36,10 +32,6 @@ const MAX_FLOOD_CELLS = 2500;
|
||||
/** World units per second for WASD keyboard panning. */
|
||||
const WASD_PAN_SPEED = 12;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Camera {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
@@ -58,10 +50,6 @@ export interface GridHandle {
|
||||
sendColorUpdate: (colors: string[]) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cellKey(x: number, y: number): string {
|
||||
return `${x},${y}`;
|
||||
}
|
||||
@@ -265,10 +253,6 @@ function clampCameraToContent(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
{ mapId, tool, paintColor, tokenColor, onColorsLoaded },
|
||||
ref,
|
||||
@@ -316,18 +300,12 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
null,
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Imperative handle — lets App.tsx trigger a color WS update
|
||||
// -------------------------------------------------------------------------
|
||||
useImperativeHandle(ref, () => ({
|
||||
sendColorUpdate(colors: string[]) {
|
||||
sendRef.current({ type: "update_colors", colors });
|
||||
},
|
||||
}));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Resize canvas to fill container
|
||||
// -------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
@@ -345,9 +323,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
return () => observer.disconnect();
|
||||
}, [redraw]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WebSocket
|
||||
// -------------------------------------------------------------------------
|
||||
// Keep a stable ref to the callback so handleMessage doesn't re-create
|
||||
const onColorsLoadedRef = useRef(onColorsLoaded);
|
||||
useEffect(() => {
|
||||
@@ -437,9 +412,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
sendRef.current = send;
|
||||
}, [send]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Canvas draw
|
||||
// -------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
@@ -518,9 +490,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
}
|
||||
}, [tick]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Camera helpers
|
||||
// -------------------------------------------------------------------------
|
||||
function applyClampAndRedraw() {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
@@ -546,9 +515,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
applyClampAndRedraw();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Wheel → zoom
|
||||
// -------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
@@ -564,9 +530,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
return () => canvas.removeEventListener("wheel", onWheel);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WASD panning — requestAnimationFrame loop
|
||||
// -------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
function rafTick(timestamp: number) {
|
||||
const keys = keysHeld.current;
|
||||
@@ -640,9 +603,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mouse helpers
|
||||
// -------------------------------------------------------------------------
|
||||
function getCanvasPoint(e: React.MouseEvent) {
|
||||
const rect = canvasRef.current!.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
@@ -655,9 +615,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mouse handlers
|
||||
// -------------------------------------------------------------------------
|
||||
function handleMouseDown(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
const { x: mx, y: my } = getCanvasPoint(e);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { auth } from "../api";
|
||||
import type { UserInfo } from "../types";
|
||||
import "./LoginModal.css";
|
||||
import { FaDiscord } from "react-icons/fa6";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -103,7 +104,7 @@ export default function LoginModal({ onClose, onLogin }: Props) {
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
minLength={1}
|
||||
minLength={3}
|
||||
maxLength={32}
|
||||
/>
|
||||
</label>
|
||||
@@ -117,7 +118,6 @@ export default function LoginModal({ onClose, onLogin }: Props) {
|
||||
tab === "login" ? "current-password" : "new-password"
|
||||
}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
{tab === "register" && (
|
||||
@@ -129,7 +129,6 @@ export default function LoginModal({ onClose, onLogin }: Props) {
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
@@ -151,9 +150,7 @@ export default function LoginModal({ onClose, onLogin }: Props) {
|
||||
|
||||
{/* Discord OAuth */}
|
||||
<button className="btn-discord" onClick={handleDiscordLogin}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
|
||||
</svg>
|
||||
<FaDiscord />
|
||||
Log In with Discord
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -108,6 +108,12 @@
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.map-list-updated {
|
||||
font-size: 0.68rem;
|
||||
color: #4b5563;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Role badge reused from EditMapModal */
|
||||
.perm-role-badge {
|
||||
font-size: 0.7rem;
|
||||
|
||||
@@ -2,6 +2,21 @@ import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import type { ListedMap } from "../types";
|
||||
import "./MapListModal.css";
|
||||
import { FaTrash } from "react-icons/fa6";
|
||||
|
||||
function timeAgo(date: Date): string {
|
||||
const diff = Date.now() - new Date(date).getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months}mo ago`;
|
||||
return `${Math.floor(months / 12)}y ago`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
maps: ListedMap[];
|
||||
@@ -83,7 +98,7 @@ export default function MapListModal({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h2>My Maps</h2>
|
||||
<h2>Maps</h2>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
@@ -95,7 +110,13 @@ export default function MapListModal({
|
||||
</p>
|
||||
) : (
|
||||
<div className="map-list-scroll">
|
||||
{maps.map((map) => (
|
||||
{[...maps]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updated_at).getTime() -
|
||||
new Date(a.updated_at).getTime(),
|
||||
)
|
||||
.map((map) => (
|
||||
<div
|
||||
key={map.id}
|
||||
className={`map-list-row ${map.id === selectedMapId ? "active" : ""}`}
|
||||
@@ -116,13 +137,21 @@ export default function MapListModal({
|
||||
{accessLabel(map.public_access)}
|
||||
</span>
|
||||
{map.user_role && (
|
||||
<span className={`perm-role-badge role-${map.user_role}`}>
|
||||
<span
|
||||
className={`perm-role-badge role-${map.user_role}`}
|
||||
>
|
||||
{map.user_role}
|
||||
</span>
|
||||
)}
|
||||
{map.is_favorited && !map.user_role && (
|
||||
<span className="map-fav-badge">★ Favorited</span>
|
||||
)}
|
||||
<span
|
||||
className="map-list-updated"
|
||||
title={new Date(map.updated_at).toLocaleString()}
|
||||
>
|
||||
{timeAgo(map.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -172,6 +201,29 @@ export default function MapListModal({
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Delete map button */}
|
||||
<button
|
||||
className="map-action-btn delete-btn"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (
|
||||
window.confirm(
|
||||
"Are you sure you want to delete this map? This action cannot be undone.",
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await api.deleteMap(map.id);
|
||||
onMapsChange(maps.filter((m) => m.id !== map.id));
|
||||
} catch (err) {
|
||||
console.error("Failed to delete map", err);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Delete map"
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// User / Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConnectionInfo {
|
||||
provider: string;
|
||||
provider_username: string | null;
|
||||
@@ -19,10 +15,6 @@ export interface UserInfo {
|
||||
connections: ConnectionInfo[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Maps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MapRole = "owner" | "editor" | "viewer";
|
||||
|
||||
/** Map visibility / editability level. */
|
||||
@@ -42,8 +34,8 @@ export interface GridMap {
|
||||
public_access: PublicAccess;
|
||||
owner_id: string; // UUID
|
||||
colors: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,10 +84,6 @@ export interface MapAccessRequest {
|
||||
|
||||
export type Tool = "pan" | "zoom" | "draw" | "token";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket message types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "paint_cell"; x: number; y: number; color: string }
|
||||
| {
|
||||
|
||||
Reference in New Issue
Block a user