init
This commit is contained in:
1
ticktalk/.dockerignore
Executable file
1
ticktalk/.dockerignore
Executable file
@@ -0,0 +1 @@
|
||||
Dockerfile
|
||||
4
ticktalk/.env
Executable file
4
ticktalk/.env
Executable file
@@ -0,0 +1,4 @@
|
||||
POSTGRES_USER=ticktalk_user
|
||||
POSTGRES_PASSWORD=ticktack
|
||||
POSTGRES_DB=ticktalk_db
|
||||
TEAM_TOKEN=7ajI7AlyA5Lu9KKA
|
||||
2596
ticktalk/Cargo.lock
generated
Executable file
2596
ticktalk/Cargo.lock
generated
Executable file
File diff suppressed because it is too large
Load Diff
9
ticktalk/Cargo.toml
Executable file
9
ticktalk/Cargo.toml
Executable file
@@ -0,0 +1,9 @@
|
||||
[workspace]
|
||||
members = ["crates/backend", "crates/db", "crates/types"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
thiserror = "2.0.17"
|
||||
uuid = { version = "1.3.0", features = ["v4", "serde"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
7
ticktalk/README.md
Executable file
7
ticktalk/README.md
Executable file
@@ -0,0 +1,7 @@
|
||||
# TickTalk Service
|
||||
|
||||
Сервис-мессенджер с Kerberos аутентификацией
|
||||
|
||||
# Deploy
|
||||
|
||||
Для запуска необходимо поменять TEAM_TOKEN в `.env` и TEAM_ID (3 октет IP адреса вашего vulnbox) в `docker/kdc/config.json`!
|
||||
BIN
ticktalk/client/kauth
Executable file
BIN
ticktalk/client/kauth
Executable file
Binary file not shown.
BIN
ticktalk/client/kdestroy
Executable file
BIN
ticktalk/client/kdestroy
Executable file
Binary file not shown.
BIN
ticktalk/client/kinit
Executable file
BIN
ticktalk/client/kinit
Executable file
Binary file not shown.
BIN
ticktalk/client/klist
Executable file
BIN
ticktalk/client/klist
Executable file
Binary file not shown.
BIN
ticktalk/client/kvno
Executable file
BIN
ticktalk/client/kvno
Executable file
Binary file not shown.
BIN
ticktalk/client/ticktalk.appimage
Executable file
BIN
ticktalk/client/ticktalk.appimage
Executable file
Binary file not shown.
99
ticktalk/compose.yml
Executable file
99
ticktalk/compose.yml
Executable file
@@ -0,0 +1,99 @@
|
||||
name: ticktalk
|
||||
services:
|
||||
db:
|
||||
container_name: mctf-ticktalk-db
|
||||
image: postgres:17.2
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
POSTGRES_MULTIPLE_USERS: "yes"
|
||||
command: |
|
||||
postgres
|
||||
-c shared_preload_libraries=pg_stat_statements
|
||||
-c pg_stat_statements.track=all
|
||||
volumes:
|
||||
- ticktalk-postgres-data:/var/lib/postgresql/data/pgdata
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
tty: true
|
||||
networks:
|
||||
- ticktalk-network
|
||||
|
||||
backend:
|
||||
container_name: mctf-ticktalk-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2228:2228"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
RUST_LOG: debug
|
||||
volumes:
|
||||
- ticktalk-kerberos-caches:/tmp
|
||||
build:
|
||||
dockerfile: docker/backend/Dockerfile
|
||||
context: .
|
||||
networks:
|
||||
- ticktalk-network
|
||||
|
||||
cleaner:
|
||||
container_name: mctf-ticktalk-cleaner
|
||||
build:
|
||||
context: docker/cleaner
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ticktalk-firmwares:/firmware
|
||||
- ticktalk-kerberos-caches:/caches
|
||||
environment:
|
||||
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
networks:
|
||||
- ticktalk-network
|
||||
|
||||
kdc:
|
||||
build:
|
||||
context: docker/kdc
|
||||
dockerfile: Dockerfile
|
||||
container_name: mctf-ticktalk-kdc
|
||||
ports:
|
||||
- "88:88"
|
||||
environment:
|
||||
- TEAM_TOKEN=${TEAM_TOKEN}
|
||||
- KDC_CONFIG=/app/config.json
|
||||
volumes:
|
||||
- ./docker/kdc/config.json:/app/config.json
|
||||
- ticktalk-kdc-data:/app/data
|
||||
depends_on:
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- ticktalk-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: mctf-ticktalk-kdc-redis
|
||||
command: ["redis-server", "--save", "60", "1", "--appendonly", "yes"]
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ticktalk-redis-data:/data
|
||||
networks:
|
||||
- ticktalk-network
|
||||
|
||||
networks:
|
||||
ticktalk-network:
|
||||
|
||||
volumes:
|
||||
ticktalk-kdc-data:
|
||||
ticktalk-redis-data:
|
||||
ticktalk-postgres-data:
|
||||
ticktalk-firmwares:
|
||||
ticktalk-kerberos-caches:
|
||||
1
ticktalk/crates/backend/.gitignore
vendored
Executable file
1
ticktalk/crates/backend/.gitignore
vendored
Executable file
@@ -0,0 +1 @@
|
||||
/target
|
||||
7
ticktalk/crates/backend/Cargo.lock
generated
Executable file
7
ticktalk/crates/backend/Cargo.lock
generated
Executable file
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ticktalk-backend"
|
||||
version = "0.1.0"
|
||||
24
ticktalk/crates/backend/Cargo.toml
Executable file
24
ticktalk/crates/backend/Cargo.toml
Executable file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "ticktalk-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4.12.1"
|
||||
tracing = "0.1.43"
|
||||
tracing-actix-web = "0.7.19"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ticktalk-db = { path = "../db" }
|
||||
ticktalk-types = { path = "../types" }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1.0.145"
|
||||
actix = "0.13.5"
|
||||
tokio = { version = "1.48.0", features = ["process"] }
|
||||
dashmap = "6.1.0"
|
||||
actix-web-actors = "4.3.1"
|
||||
base64 = "0.22"
|
||||
actix-cors = "0.7.1"
|
||||
futures-util = "0.3"
|
||||
72
ticktalk/crates/backend/src/error.rs
Executable file
72
ticktalk/crates/backend/src/error.rs
Executable file
@@ -0,0 +1,72 @@
|
||||
use actix_web::HttpResponse;
|
||||
use actix_web::error::ResponseError;
|
||||
use serde_json;
|
||||
use thiserror::Error;
|
||||
use ticktalk_db::errors::RepositoryError;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum WebError {
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseError(#[from] RepositoryError),
|
||||
#[error("Invalid UUID: {0}")]
|
||||
InvalidUuid(#[from] uuid::Error),
|
||||
#[error("Not found")]
|
||||
NotFound,
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("Bad request")]
|
||||
BadRequest(String),
|
||||
#[error("Internal server error")]
|
||||
InternalServerError(String),
|
||||
}
|
||||
|
||||
impl ResponseError for WebError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
match self {
|
||||
WebError::DatabaseError(_) => {
|
||||
error!("Database error");
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Database error occurred",
|
||||
"message": "An error occurred while accessing the database"
|
||||
}))
|
||||
}
|
||||
WebError::InvalidUuid(e) => {
|
||||
error!("Invalid UUID error: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Invalid UUID",
|
||||
"message": "An error occurred while parsing the UUID"
|
||||
}))
|
||||
}
|
||||
WebError::NotFound => {
|
||||
error!("Resource not found");
|
||||
HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "Not found",
|
||||
"message": "The requested resource was not found"
|
||||
}))
|
||||
}
|
||||
WebError::Unauthorized => {
|
||||
warn!("Unauthorized access attempt");
|
||||
HttpResponse::Unauthorized().json(serde_json::json!({
|
||||
"error": "Unauthorized",
|
||||
"message": "Access denied"
|
||||
}))
|
||||
}
|
||||
WebError::InternalServerError(e) => {
|
||||
error!("Internal server error: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Internal server error",
|
||||
"message": "An unexpected error occurred"
|
||||
}))
|
||||
}
|
||||
|
||||
WebError::BadRequest(msg) => {
|
||||
error!("Bad Request: {}", msg);
|
||||
HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "Bad Request",
|
||||
"message": msg
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
ticktalk/crates/backend/src/handlers/http.rs
Executable file
191
ticktalk/crates/backend/src/handlers/http.rs
Executable file
@@ -0,0 +1,191 @@
|
||||
use crate::error::WebError;
|
||||
use crate::services::*;
|
||||
use crate::session::{SessionLayer, SessionClaims, SessionManager};
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use serde::Deserialize;
|
||||
use ticktalk_types::{LoginRequest, *};
|
||||
use tracing::{debug, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_chat(
|
||||
req: HttpRequest,
|
||||
service: web::Data<ChatService>,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
chat_id = %path,
|
||||
"get_chat request"
|
||||
);
|
||||
let user_id = require_user(&req)?;
|
||||
let chat = service.get_chat(*path).await?;
|
||||
if chat.first_user_id != user_id && chat.second_user_id != user_id {
|
||||
return Err(WebError::Unauthorized);
|
||||
}
|
||||
Ok(HttpResponse::Ok().json(chat))
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
req: HttpRequest,
|
||||
session_manager: web::Data<SessionManager>,
|
||||
service: web::Data<AuthService>,
|
||||
data: web::Json<LoginRequest>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
"login attempt"
|
||||
);
|
||||
|
||||
let login_payload = data.into_inner();
|
||||
info!(target: "auth", "Login attempt for ticket len={}", login_payload.ticket.len());
|
||||
let user = service.login(login_payload).await?;
|
||||
info!(target: "auth", user_id = %user.id, username = %user.username, "Kerberos login successful");
|
||||
let session_id = session_manager
|
||||
.create_session(user.id, user.username.clone());
|
||||
debug!(target: "auth", session_id = %session_id, "Session created for Kerberos user");
|
||||
let response = LoginResponse {
|
||||
session_id,
|
||||
user,
|
||||
};
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
}
|
||||
|
||||
pub async fn create_chat(
|
||||
req: HttpRequest,
|
||||
service: web::Data<ChatService>,
|
||||
data: web::Json<CreateChatRequest>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
"create_chat request"
|
||||
);
|
||||
let user_id = require_user(&req)?;
|
||||
if data.first_user_id != user_id {
|
||||
return Err(WebError::Unauthorized);
|
||||
}
|
||||
let chat = service.create_chat(data.into_inner()).await?;
|
||||
Ok(HttpResponse::Ok().json(chat))
|
||||
}
|
||||
|
||||
pub async fn get_user_chats(
|
||||
req: HttpRequest,
|
||||
service: web::Data<ChatService>,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
user_id = %path,
|
||||
"get_user_chats request"
|
||||
);
|
||||
let user_id = require_user(&req)?;
|
||||
if user_id != *path {
|
||||
return Err(WebError::Unauthorized);
|
||||
}
|
||||
let chats = service.get_user_chats(user_id).await?;
|
||||
Ok(HttpResponse::Ok().json(chats))
|
||||
}
|
||||
|
||||
pub async fn get_chat_messages(
|
||||
req: HttpRequest,
|
||||
service: web::Data<ChatService>,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
chat_id = %path,
|
||||
"get_chat_messages request"
|
||||
);
|
||||
let user_id = require_user(&req)?;
|
||||
let chat = service.get_chat(*path).await?;
|
||||
if chat.first_user_id != user_id && chat.second_user_id != user_id {
|
||||
return Err(WebError::Unauthorized);
|
||||
}
|
||||
let messages = service.get_chat_messages(chat.id).await?;
|
||||
Ok(HttpResponse::Ok().json(messages))
|
||||
}
|
||||
|
||||
pub async fn create_message(
|
||||
req: HttpRequest,
|
||||
service: web::Data<ChatService>,
|
||||
data: web::Json<CreateMessageRequest>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
"create_message request"
|
||||
);
|
||||
let user_id = require_user(&req)?;
|
||||
let payload = data.into_inner();
|
||||
if payload.sender_id != user_id {
|
||||
return Err(WebError::Unauthorized);
|
||||
}
|
||||
let chat = service.get_chat(payload.chat_id).await?;
|
||||
if chat.first_user_id != user_id && chat.second_user_id != user_id {
|
||||
return Err(WebError::Unauthorized);
|
||||
}
|
||||
let message = service.create_message(payload).await?;
|
||||
Ok(HttpResponse::Ok().json(message))
|
||||
}
|
||||
|
||||
pub async fn get_user_data(
|
||||
req: HttpRequest,
|
||||
service: web::Data<UserService>,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
let _requester = require_user(&req)?;
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
user_id = %path,
|
||||
"get_user_data request"
|
||||
);
|
||||
let user = service.get_user(*path).await?;
|
||||
Ok(HttpResponse::Ok().json(user))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UsernameQuery {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(
|
||||
req: HttpRequest,
|
||||
service: web::Data<UserService>,
|
||||
query: web::Query<UsernameQuery>,
|
||||
) -> Result<HttpResponse, WebError> {
|
||||
let _requester = require_user(&req)?;
|
||||
info!(
|
||||
target: "http",
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
username = %query.username,
|
||||
"get_user_by_username request"
|
||||
);
|
||||
let user = service.get_user_by_username(&query.username).await?;
|
||||
Ok(HttpResponse::Ok().json(user))
|
||||
}
|
||||
|
||||
fn require_user(req: &HttpRequest) -> Result<Uuid, WebError> {
|
||||
let claims = require_session(req)?;
|
||||
debug!(target: "auth", user_id = %claims.user_id, session_id = %claims.session_id, "Session user retrieved");
|
||||
Ok(claims.user_id)
|
||||
}
|
||||
|
||||
fn require_session(req: &HttpRequest) -> Result<SessionClaims, WebError> {
|
||||
SessionLayer::claims(req).ok_or_else(|| {
|
||||
debug!(target: "auth", "Missing or invalid session header");
|
||||
WebError::Unauthorized
|
||||
})
|
||||
}
|
||||
1
ticktalk/crates/backend/src/handlers/mod.rs
Executable file
1
ticktalk/crates/backend/src/handlers/mod.rs
Executable file
@@ -0,0 +1 @@
|
||||
pub mod http;
|
||||
150
ticktalk/crates/backend/src/handlers/websocket.rs
Executable file
150
ticktalk/crates/backend/src/handlers/websocket.rs
Executable file
@@ -0,0 +1,150 @@
|
||||
use actix::AsyncContext;
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
use actix_web_actors::ws;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Простой менеджер: user_id -> адрес WS соединения
|
||||
#[derive(Clone)]
|
||||
pub struct WsManager {
|
||||
connections: Arc<Mutex<HashMap<Uuid, actix::Addr<WsActor>>>>,
|
||||
}
|
||||
|
||||
impl WsManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connections: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&self, user_id: Uuid, addr: actix::Addr<WsActor>) {
|
||||
self.connections.lock().unwrap().insert(user_id, addr);
|
||||
}
|
||||
|
||||
pub fn unregister(&self, user_id: &Uuid) {
|
||||
self.connections.lock().unwrap().remove(user_id);
|
||||
}
|
||||
|
||||
pub fn send_to_user(&self, user_id: Uuid, message: String) -> bool {
|
||||
if let Some(addr) = self.connections.lock().unwrap().get(&user_id) {
|
||||
addr.do_send(SendToClient(message));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сообщение для отправки клиенту
|
||||
struct SendToClient(pub String);
|
||||
|
||||
impl actix::Message for SendToClient {
|
||||
type Result = ();
|
||||
}
|
||||
|
||||
// WebSocket актор
|
||||
pub struct WsActor {
|
||||
user_id: Option<Uuid>,
|
||||
manager: WsManager,
|
||||
}
|
||||
|
||||
impl actix::Actor for WsActor {
|
||||
type Context = ws::WebsocketContext<Self>;
|
||||
|
||||
fn started(&mut self, _: &mut Self::Context) {
|
||||
info!("WebSocket started");
|
||||
}
|
||||
|
||||
fn stopped(&mut self, _ctx: &mut Self::Context) {
|
||||
if let Some(user_id) = self.user_id {
|
||||
self.manager.unregister(&user_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обработчик входящих сообщений
|
||||
impl actix::StreamHandler<Result<ws::Message, ws::ProtocolError>> for WsActor {
|
||||
fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
|
||||
match msg {
|
||||
Ok(ws::Message::Text(text)) => {
|
||||
self.handle_message(&text, ctx);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обработчик SendToClient сообщений
|
||||
impl actix::Handler<SendToClient> for WsActor {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: SendToClient, ctx: &mut Self::Context) {
|
||||
ctx.text(msg.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl WsActor {
|
||||
fn handle_message(&mut self, text: &str, ctx: &mut ws::WebsocketContext<Self>) {
|
||||
// Парсим JSON
|
||||
if let Ok(data) = serde_json::from_str::<serde_json::Value>(text) {
|
||||
match data.get("type").and_then(|t| t.as_str()) {
|
||||
Some("auth") => {
|
||||
// Аутентификация
|
||||
if let Some(user_id_str) = data.get("user_id").and_then(|u| u.as_str()) {
|
||||
if let Ok(user_id) = Uuid::parse_str(user_id_str) {
|
||||
self.user_id = Some(user_id);
|
||||
self.manager.register(user_id, ctx.address());
|
||||
|
||||
ctx.text(r#"{"type": "auth_success"}"#);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("message") => {
|
||||
if let (Some(to_user_str), Some(content), Some(chat_id_str)) = (
|
||||
data.get("to_user").and_then(|t| t.as_str()),
|
||||
data.get("content").and_then(|c| c.as_str()),
|
||||
data.get("chat_id").and_then(|c| c.as_str()),
|
||||
) {
|
||||
if let (Some(from_user), Ok(to_user), Ok(chat_id)) = (
|
||||
self.user_id,
|
||||
Uuid::parse_str(to_user_str),
|
||||
Uuid::parse_str(chat_id_str),
|
||||
) {
|
||||
// 1. Сохраняем в БД
|
||||
// message_repo.create(...).await;
|
||||
|
||||
// 2. Отправляем получателю
|
||||
let message_json = serde_json::json!({
|
||||
"type": "new_message",
|
||||
"from": from_user,
|
||||
"chat_id": chat_id,
|
||||
"content": content,
|
||||
"timestamp": chrono::Utc::now().timestamp()
|
||||
});
|
||||
|
||||
self.manager.send_to_user(to_user, message_json.to_string());
|
||||
|
||||
ctx.text(r#"{"type": "message_sent"}"#);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ws_handler(
|
||||
req: HttpRequest,
|
||||
stream: web::Payload,
|
||||
manager: web::Data<WsManager>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let actor = WsActor {
|
||||
user_id: None,
|
||||
manager: manager.get_ref().clone(),
|
||||
};
|
||||
|
||||
ws::start(actor, &req, stream)
|
||||
}
|
||||
71
ticktalk/crates/backend/src/main.rs
Executable file
71
ticktalk/crates/backend/src/main.rs
Executable file
@@ -0,0 +1,71 @@
|
||||
use crate::services::*;
|
||||
use crate::session::{SessionLayer, SessionManager};
|
||||
use actix_cors::Cors;
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use ticktalk_db::create_db_pool;
|
||||
use ticktalk_db::repositories::{ChatRepository, MessageRepository, UserRepository};
|
||||
use tracing::{error, info};
|
||||
use tracing_actix_web::TracingLogger;
|
||||
use tracing_subscriber::filter::EnvFilter;
|
||||
|
||||
mod error;
|
||||
mod handlers;
|
||||
mod routes;
|
||||
mod services;
|
||||
mod session;
|
||||
mod types;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
if tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.try_init()
|
||||
.is_err()
|
||||
{
|
||||
error!("Tracing subscriber already initialised");
|
||||
}
|
||||
info!("Starting TickTalk backend");
|
||||
|
||||
let db_pool = create_db_pool().await;
|
||||
|
||||
let user_repo = UserRepository::new(db_pool.clone());
|
||||
let chat_repo = ChatRepository::new(db_pool.clone());
|
||||
let message_repo = MessageRepository::new(db_pool.clone());
|
||||
|
||||
let chat_service = ChatService::new(chat_repo, message_repo);
|
||||
let user_service = UserService::new(user_repo.clone());
|
||||
let auth_service = AuthService::new(user_repo);
|
||||
let session_manager = SessionManager::new();
|
||||
|
||||
HttpServer::new(move || {
|
||||
let session_manager = session_manager.clone();
|
||||
let cors = Cors::default()
|
||||
.allowed_origin_fn(|origin, _| {
|
||||
origin
|
||||
.to_str()
|
||||
.map(|value| {
|
||||
value.starts_with("tauri://")
|
||||
|| value.starts_with("http://")
|
||||
|| value.starts_with("https://")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.allow_any_method()
|
||||
.allow_any_header()
|
||||
.supports_credentials();
|
||||
App::new()
|
||||
.app_data(web::Data::new(session_manager.clone()))
|
||||
.wrap(SessionLayer::new(session_manager))
|
||||
.wrap(cors)
|
||||
.wrap(TracingLogger::default())
|
||||
.app_data(web::Data::new(chat_service.clone()))
|
||||
.app_data(web::Data::new(auth_service.clone()))
|
||||
.app_data(web::Data::new(user_service.clone()))
|
||||
.configure(routes::configure_routes)
|
||||
})
|
||||
.bind("0.0.0.0:2228")?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
16
ticktalk/crates/backend/src/routes.rs
Executable file
16
ticktalk/crates/backend/src/routes.rs
Executable file
@@ -0,0 +1,16 @@
|
||||
use crate::handlers::http::*;
|
||||
use actix_web::web;
|
||||
|
||||
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/api")
|
||||
.route("/chat", web::post().to(create_chat))
|
||||
.route("/chat/user/{user_id}", web::get().to(get_user_chats))
|
||||
.route("/chat/{chat_id}", web::get().to(get_chat))
|
||||
.route("/chat/{chat_id}/message", web::get().to(get_chat_messages))
|
||||
.route("/chat/{chat_id}/message", web::post().to(create_message))
|
||||
.route("/user/by-username", web::get().to(get_user_by_username))
|
||||
.route("/user/{user_id}", web::get().to(get_user_data))
|
||||
.route("/auth/login", web::post().to(login)),
|
||||
);
|
||||
}
|
||||
211
ticktalk/crates/backend/src/services/auth.rs
Executable file
211
ticktalk/crates/backend/src/services/auth.rs
Executable file
@@ -0,0 +1,211 @@
|
||||
use crate::error::WebError;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use std::convert::TryInto;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use ticktalk_db::{models::NewUser, repositories::UserRepository};
|
||||
use ticktalk_types::{LoginRequest, UserResponse};
|
||||
use tokio::process::Command;
|
||||
use tracing::debug;
|
||||
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
user_repo: UserRepository,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub fn new(user_repo: UserRepository) -> Self {
|
||||
Self { user_repo }
|
||||
}
|
||||
|
||||
pub async fn login(&self, creds: LoginRequest) -> Result<UserResponse, WebError> {
|
||||
let ticket_bytes = STANDARD
|
||||
.decode(creds.ticket.as_bytes())
|
||||
.map_err(|_| WebError::BadRequest("Invalid ticket payload".into()))?;
|
||||
|
||||
let parsed: ParsedTicket = ParsedTicket::from_bytes(&ticket_bytes)?;
|
||||
let username = normalize_username(&parsed.client_principal);
|
||||
|
||||
let ticket_cache_path = format!("/tmp/user_{}.cache", username);
|
||||
let cache_exists = Path::new(&ticket_cache_path).exists();
|
||||
if !cache_exists {
|
||||
debug!(
|
||||
target: "auth::ticket",
|
||||
"No cache for {}, will persist received ticket",
|
||||
username
|
||||
);
|
||||
fs::write(&ticket_cache_path, &ticket_bytes)
|
||||
.map_err(|e| WebError::InternalServerError(format!("Failed to persist ticket cache: {e}")))?;
|
||||
|
||||
}
|
||||
|
||||
Self::login_via_kerberos(
|
||||
&ticket_cache_path,
|
||||
&parsed.client_principal,
|
||||
&normalize_realm(&parsed.service_principal),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(user) = self
|
||||
.user_repo
|
||||
.find_by_username(username.clone())
|
||||
.await?
|
||||
{
|
||||
return Ok(UserResponse {
|
||||
id: user.id,
|
||||
username,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
let created = self
|
||||
.user_repo
|
||||
.create(NewUser {
|
||||
username,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(UserResponse {
|
||||
id: created.id,
|
||||
username: created.username,
|
||||
})
|
||||
}
|
||||
|
||||
async fn login_via_kerberos(tgs_path: &str, principal: &str, realm: &str) -> Result<(), WebError> {
|
||||
let service_principal = format!("service@{}", realm);
|
||||
|
||||
debug!(
|
||||
target: "auth::kerberos",
|
||||
"Invoking kauth --cache {tgs_path} {principal} {service_principal} kdc"
|
||||
);
|
||||
|
||||
let output = Command::new("kauth")
|
||||
.arg("--cache")
|
||||
.arg(tgs_path)
|
||||
.arg(principal)
|
||||
.arg(&service_principal)
|
||||
.arg("kdc")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| WebError::InternalServerError(format!("Kerberos exec failed: {e}")))?;
|
||||
|
||||
debug!(
|
||||
target: "auth::kerberos",
|
||||
status = %output.status,
|
||||
"Kerberos helper finished"
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if stdout.contains("OK") {
|
||||
return Ok(());
|
||||
} else if stdout.contains("FAIL") {
|
||||
return Err(WebError::Unauthorized)
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(WebError::InternalServerError(format!(
|
||||
"Kerberos rejected {} via {}: {}",
|
||||
principal, service_principal, stderr.trim()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_username(principal: &str) -> String {
|
||||
principal.split('@').next().unwrap_or(principal).to_string()
|
||||
}
|
||||
|
||||
fn normalize_realm(principal: &str) -> String {
|
||||
principal
|
||||
.split('@')
|
||||
.nth(1)
|
||||
.map(|realm| realm.trim().to_ascii_uppercase())
|
||||
.filter(|realm| !realm.is_empty())
|
||||
.unwrap_or_else(|| "TICKTALK.LOCAL".to_string())
|
||||
}
|
||||
fn ticket_cache_dir() -> PathBuf {
|
||||
PathBuf::from("/tmp/ticktalk-cache")
|
||||
}
|
||||
|
||||
fn ticket_cache_path(username: &str) -> PathBuf {
|
||||
ticket_cache_dir().join(format!("user_{}.cache", username))
|
||||
}
|
||||
|
||||
struct ParsedTicket {
|
||||
ticket_id: String,
|
||||
client_principal: String,
|
||||
service_principal: String,
|
||||
realm: String,
|
||||
expires_at: u64,
|
||||
forwardable: bool,
|
||||
is_tgt: bool,
|
||||
ticket_blob: Vec<u8>,
|
||||
client_key: String,
|
||||
}
|
||||
|
||||
impl ParsedTicket {
|
||||
fn from_bytes(data: &[u8]) -> Result<Self, WebError> {
|
||||
let mut offset = 0usize;
|
||||
|
||||
fn ensure(data: &[u8], offset: usize, needed: usize) -> Result<(), WebError> {
|
||||
if offset + needed > data.len() {
|
||||
return Err(WebError::BadRequest("Ticket blob truncated".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_u32(data: &[u8], offset: &mut usize) -> Result<u32, WebError> {
|
||||
ensure(data, *offset, 4)?;
|
||||
let value = u32::from_be_bytes(data[*offset..*offset + 4].try_into().unwrap());
|
||||
*offset += 4;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn read_u64(data: &[u8], offset: &mut usize) -> Result<u64, WebError> {
|
||||
ensure(data, *offset, 8)?;
|
||||
let value = u64::from_be_bytes(data[*offset..*offset + 8].try_into().unwrap());
|
||||
*offset += 8;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn read_string(data: &[u8], offset: &mut usize) -> Result<String, WebError> {
|
||||
let len = read_u32(data, offset)? as usize;
|
||||
ensure(data, *offset, len)?;
|
||||
let slice = &data[*offset..*offset + len];
|
||||
*offset += len;
|
||||
String::from_utf8(slice.to_vec())
|
||||
.map_err(|_| WebError::BadRequest("Ticket string is not UTF-8".into()))
|
||||
}
|
||||
|
||||
let ticket_id = read_string(data, &mut offset)?;
|
||||
let service_principal = read_string(data, &mut offset)?;
|
||||
let client_principal = read_string(data, &mut offset)?;
|
||||
let realm = read_string(data, &mut offset)?;
|
||||
let expires_at = read_u64(data, &mut offset)?;
|
||||
|
||||
ensure(data, offset, 2)?;
|
||||
let forwardable = data[offset] != 0;
|
||||
offset += 1;
|
||||
let is_tgt = data[offset] != 0;
|
||||
offset += 1;
|
||||
|
||||
let ticket_blob_len = read_u32(data, &mut offset)? as usize;
|
||||
ensure(data, offset, ticket_blob_len)?;
|
||||
let ticket_blob = data[offset..offset + ticket_blob_len].to_vec();
|
||||
offset += ticket_blob_len;
|
||||
|
||||
let client_key = read_string(data, &mut offset)?;
|
||||
|
||||
Ok(Self {
|
||||
ticket_id,
|
||||
client_principal,
|
||||
service_principal,
|
||||
realm,
|
||||
expires_at,
|
||||
forwardable,
|
||||
is_tgt,
|
||||
ticket_blob,
|
||||
client_key,
|
||||
})
|
||||
}
|
||||
}
|
||||
91
ticktalk/crates/backend/src/services/chat.rs
Executable file
91
ticktalk/crates/backend/src/services/chat.rs
Executable file
@@ -0,0 +1,91 @@
|
||||
use crate::error::WebError;
|
||||
use ticktalk_db::models::{Chat, Message, NewChat, NewMessage};
|
||||
use ticktalk_db::repositories::{ChatRepository, MessageRepository};
|
||||
use ticktalk_types::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ChatService {
|
||||
chat_repo: ChatRepository,
|
||||
message_repo: MessageRepository,
|
||||
}
|
||||
|
||||
impl ChatService {
|
||||
pub fn new(chat_repo: ChatRepository, message_repo: MessageRepository) -> Self {
|
||||
Self {
|
||||
chat_repo,
|
||||
message_repo,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_chat(&self, chat_id: Uuid) -> Result<ChatResponse, WebError> {
|
||||
match self.chat_repo.find_by_id(chat_id).await {
|
||||
Ok(Some(chat)) => Ok(ChatResponse {
|
||||
id: chat.id,
|
||||
first_user_id: chat.first_user_id,
|
||||
second_user_id: chat.second_user_id,
|
||||
}),
|
||||
Ok(None) => Err(WebError::NotFound),
|
||||
Err(err) => Err(WebError::DatabaseError(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_chat(&self, chat_req: CreateChatRequest) -> Result<ChatResponse, WebError> {
|
||||
let new_chat = NewChat {
|
||||
first_user_id: chat_req.first_user_id,
|
||||
second_user_id: chat_req.second_user_id,
|
||||
};
|
||||
|
||||
let chat = self.chat_repo.create(new_chat).await?;
|
||||
Ok(chat_into_response(chat))
|
||||
}
|
||||
|
||||
pub async fn get_chat_messages(&self, chat_id: Uuid) -> Result<Vec<MessageResponse>, WebError> {
|
||||
let messages = self
|
||||
.message_repo
|
||||
.find_by_chat_id(chat_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(message_into_response)
|
||||
.collect::<Vec<MessageResponse>>();
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub async fn create_message(
|
||||
&self,
|
||||
message_req: CreateMessageRequest,
|
||||
) -> Result<MessageResponse, WebError> {
|
||||
let new_message = NewMessage {
|
||||
chat_id: message_req.chat_id,
|
||||
sender_id: message_req.sender_id,
|
||||
recipient_id: message_req.recipient_id,
|
||||
content: message_req.content,
|
||||
};
|
||||
|
||||
let message = self.message_repo.create(new_message).await?;
|
||||
Ok(message_into_response(message))
|
||||
}
|
||||
|
||||
pub async fn get_user_chats(&self, user_id: Uuid) -> Result<Vec<ChatResponse>, WebError> {
|
||||
let chats = self.chat_repo.find_by_user(user_id).await?;
|
||||
Ok(chats.into_iter().map(chat_into_response).collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_into_response(chat: Chat) -> ChatResponse {
|
||||
ChatResponse {
|
||||
id: chat.id,
|
||||
first_user_id: chat.first_user_id,
|
||||
second_user_id: chat.second_user_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn message_into_response(message: Message) -> MessageResponse {
|
||||
MessageResponse {
|
||||
sender_id: message.sender_id,
|
||||
recipient_id: message.recipient_id,
|
||||
content: message.content,
|
||||
created_at: message.created_at,
|
||||
}
|
||||
}
|
||||
7
ticktalk/crates/backend/src/services/mod.rs
Executable file
7
ticktalk/crates/backend/src/services/mod.rs
Executable file
@@ -0,0 +1,7 @@
|
||||
pub mod auth;
|
||||
pub mod chat;
|
||||
pub mod user;
|
||||
|
||||
pub use self::auth::*;
|
||||
pub use self::chat::*;
|
||||
pub use self::user::*;
|
||||
38
ticktalk/crates/backend/src/services/user.rs
Executable file
38
ticktalk/crates/backend/src/services/user.rs
Executable file
@@ -0,0 +1,38 @@
|
||||
use crate::error::WebError;
|
||||
use ticktalk_db::{repositories::UserRepository};
|
||||
use ticktalk_types::UserResponse;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UserService {
|
||||
user_repo: UserRepository,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub fn new(user_repo: UserRepository) -> Self {
|
||||
Self { user_repo }
|
||||
}
|
||||
|
||||
pub async fn get_user(&self, id: Uuid) -> Result<UserResponse, WebError> {
|
||||
match self.user_repo.find_by_id(id).await? {
|
||||
Some(user) => Ok(UserResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
}),
|
||||
None => Err(WebError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(
|
||||
&self,
|
||||
username_query: &str,
|
||||
) -> Result<UserResponse, WebError> {
|
||||
match self.user_repo.find_by_username(username_query.to_string()).await? {
|
||||
Some(user) => Ok(UserResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
}),
|
||||
None => Err(WebError::NotFound),
|
||||
}
|
||||
}
|
||||
}
|
||||
132
ticktalk/crates/backend/src/session.rs
Executable file
132
ticktalk/crates/backend/src/session.rs
Executable file
@@ -0,0 +1,132 @@
|
||||
use actix_web::{
|
||||
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
|
||||
Error, HttpMessage, HttpRequest,
|
||||
};
|
||||
use dashmap::DashMap;
|
||||
use futures_util::future::{ready, LocalBoxFuture, Ready};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
const SESSION_HEADER: &str = "X-Session-Id";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionManager {
|
||||
sessions: Arc<DashMap<Uuid, SessionRecord>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionRecord {
|
||||
pub user_id: Uuid,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionClaims {
|
||||
pub session_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_session(&self, user_id: Uuid, username: String) -> Uuid {
|
||||
let session_id = Uuid::new_v4();
|
||||
self.sessions.insert(
|
||||
session_id,
|
||||
SessionRecord {
|
||||
user_id,
|
||||
username,
|
||||
},
|
||||
);
|
||||
session_id
|
||||
}
|
||||
|
||||
pub fn get(&self, session_id: &Uuid) -> Option<SessionRecord> {
|
||||
self.sessions.get(session_id).map(|entry| entry.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionLayer {
|
||||
manager: SessionManager,
|
||||
}
|
||||
|
||||
impl SessionLayer {
|
||||
pub fn new(manager: SessionManager) -> Self {
|
||||
Self { manager }
|
||||
}
|
||||
|
||||
pub fn claims(req: &HttpRequest) -> Option<SessionClaims> {
|
||||
req.extensions().get::<SessionClaims>().cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, B> Transform<S, ServiceRequest> for SessionLayer
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
|
||||
S::Future: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
type InitError = ();
|
||||
type Transform = SessionMiddleware<S>;
|
||||
type Future = Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
ready(Ok(SessionMiddleware {
|
||||
service,
|
||||
manager: self.manager.clone(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionMiddleware<S> {
|
||||
service: S,
|
||||
manager: SessionManager,
|
||||
}
|
||||
|
||||
impl<S, B> Service<ServiceRequest> for SessionMiddleware<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
|
||||
S::Future: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||
|
||||
forward_ready!(service);
|
||||
|
||||
fn call(&self, mut req: ServiceRequest) -> Self::Future {
|
||||
let manager = self.manager.clone();
|
||||
|
||||
if let Some(claims) = extract_claims(&req, &manager) {
|
||||
req.extensions_mut().insert(claims);
|
||||
}
|
||||
|
||||
let fut = self.service.call(req);
|
||||
|
||||
Box::pin(async move {
|
||||
let res = fut.await?;
|
||||
Ok(res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_claims(req: &ServiceRequest, manager: &SessionManager) -> Option<SessionClaims> {
|
||||
let session_id = req
|
||||
.headers()
|
||||
.get(SESSION_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| Uuid::parse_str(value).ok())?;
|
||||
|
||||
manager.get(&session_id).map(|record| SessionClaims {
|
||||
session_id,
|
||||
user_id: record.user_id,
|
||||
username: record.username,
|
||||
})
|
||||
}
|
||||
1
ticktalk/crates/backend/src/types.rs
Executable file
1
ticktalk/crates/backend/src/types.rs
Executable file
@@ -0,0 +1 @@
|
||||
|
||||
1
ticktalk/crates/db/.env
Executable file
1
ticktalk/crates/db/.env
Executable file
@@ -0,0 +1 @@
|
||||
DATABASE_URL=postgres://ticktalk_user:ticktack@localhost:5432/ticktalk_db
|
||||
15
ticktalk/crates/db/Cargo.toml
Executable file
15
ticktalk/crates/db/Cargo.toml
Executable file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ticktalk-db"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true }
|
||||
bb8 = "0.9.0"
|
||||
diesel = { version = "2.2.0", features = ["postgres", "chrono", "uuid"] }
|
||||
diesel-async = { version = "0.7.4", features = ["postgres", "pool", "bb8"] }
|
||||
diesel-derive-enum = { version = "2.1.0", features = ["postgres"] }
|
||||
diesel_migrations = "2.3.0"
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
dotenv = "0.15.0"
|
||||
9
ticktalk/crates/db/diesel.toml
Executable file
9
ticktalk/crates/db/diesel.toml
Executable file
@@ -0,0 +1,9 @@
|
||||
# For documentation on how to configure this file,
|
||||
# see https://diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/schema.rs"
|
||||
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
||||
|
||||
[migrations_directory]
|
||||
dir = "migrations"
|
||||
0
ticktalk/crates/db/migrations/.diesel_lock
Executable file
0
ticktalk/crates/db/migrations/.diesel_lock
Executable file
0
ticktalk/crates/db/migrations/.keep
Executable file
0
ticktalk/crates/db/migrations/.keep
Executable file
@@ -0,0 +1,6 @@
|
||||
-- This file was automatically created by Diesel to setup helper functions
|
||||
-- and other internal bookkeeping. This file is safe to edit, any future
|
||||
-- changes will be added to existing projects as new migrations.
|
||||
|
||||
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
|
||||
DROP FUNCTION IF EXISTS diesel_set_updated_at();
|
||||
36
ticktalk/crates/db/migrations/00000000000000_diesel_initial_setup/up.sql
Executable file
36
ticktalk/crates/db/migrations/00000000000000_diesel_initial_setup/up.sql
Executable file
@@ -0,0 +1,36 @@
|
||||
-- This file was automatically created by Diesel to setup helper functions
|
||||
-- and other internal bookkeeping. This file is safe to edit, any future
|
||||
-- changes will be added to existing projects as new migrations.
|
||||
|
||||
|
||||
|
||||
|
||||
-- Sets up a trigger for the given table to automatically set a column called
|
||||
-- `updated_at` whenever the row is modified (unless `updated_at` was included
|
||||
-- in the modified columns)
|
||||
--
|
||||
-- # Example
|
||||
--
|
||||
-- ```sql
|
||||
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
|
||||
--
|
||||
-- SELECT diesel_manage_updated_at('users');
|
||||
-- ```
|
||||
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
|
||||
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF (
|
||||
NEW IS DISTINCT FROM OLD AND
|
||||
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
|
||||
) THEN
|
||||
NEW.updated_at := current_timestamp;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
3
ticktalk/crates/db/migrations/2025-12-10-190915-0000_setup_db/down.sql
Executable file
3
ticktalk/crates/db/migrations/2025-12-10-190915-0000_setup_db/down.sql
Executable file
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS messages;
|
||||
DROP TABLE IF EXISTS chats;
|
||||
DROP TABLE IF EXISTS users;
|
||||
28
ticktalk/crates/db/migrations/2025-12-10-190915-0000_setup_db/up.sql
Executable file
28
ticktalk/crates/db/migrations/2025-12-10-190915-0000_setup_db/up.sql
Executable file
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chats (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
first_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
second_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
UNIQUE(first_user_id, second_user_id),
|
||||
CHECK (first_user_id != second_user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
recipient_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_sender_id ON messages(sender_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_created ON messages(chat_id, created_at);
|
||||
25
ticktalk/crates/db/src/errors.rs
Executable file
25
ticktalk/crates/db/src/errors.rs
Executable file
@@ -0,0 +1,25 @@
|
||||
use diesel_async::pooled_connection::bb8::RunError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RepositoryError {
|
||||
#[error("Database error: {0}")]
|
||||
Query(#[from] diesel::result::Error),
|
||||
|
||||
#[error("Connection error: {0}")]
|
||||
Connection(#[from] diesel::ConnectionError),
|
||||
|
||||
#[error("Pool error: {0}")]
|
||||
Pool(#[from] RunError),
|
||||
|
||||
#[error("Not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("Already exists")]
|
||||
AlreadyExists,
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("Database conflict")]
|
||||
Conflict,
|
||||
}
|
||||
32
ticktalk/crates/db/src/lib.rs
Executable file
32
ticktalk/crates/db/src/lib.rs
Executable file
@@ -0,0 +1,32 @@
|
||||
pub mod errors;
|
||||
pub mod models;
|
||||
pub mod repositories;
|
||||
mod schema;
|
||||
|
||||
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
|
||||
use diesel_migrations::{EmbeddedMigrations, embed_migrations};
|
||||
use std::env;
|
||||
|
||||
pub use diesel_async::AsyncPgConnection;
|
||||
|
||||
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||
|
||||
pub type DbPool = bb8::Pool<AsyncDieselConnectionManager<AsyncPgConnection>>;
|
||||
|
||||
fn database_url() -> String {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
dotenv::dotenv().ok();
|
||||
}
|
||||
env::var("DATABASE_URL").expect("DATABASE_URL must be set")
|
||||
}
|
||||
|
||||
pub async fn create_db_pool() -> DbPool {
|
||||
let database_url = database_url();
|
||||
let config = AsyncDieselConnectionManager::<AsyncPgConnection>::new(database_url);
|
||||
|
||||
DbPool::builder()
|
||||
.build(config)
|
||||
.await
|
||||
.expect("Failed to create pool")
|
||||
}
|
||||
56
ticktalk/crates/db/src/models.rs
Executable file
56
ticktalk/crates/db/src/models.rs
Executable file
@@ -0,0 +1,56 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Selectable, Identifiable)]
|
||||
#[diesel(table_name = crate::schema::users)]
|
||||
#[diesel(check_for_backend(diesel::pg::Pg))]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Insertable)]
|
||||
#[diesel(table_name = crate::schema::users)]
|
||||
pub struct NewUser {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Selectable, Identifiable)]
|
||||
#[diesel(table_name = crate::schema::chats)]
|
||||
#[diesel(check_for_backend(diesel::pg::Pg))]
|
||||
pub struct Chat {
|
||||
pub id: Uuid,
|
||||
pub first_user_id: Uuid,
|
||||
pub second_user_id: Uuid,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Insertable)]
|
||||
#[diesel(table_name = crate::schema::chats)]
|
||||
pub struct NewChat {
|
||||
pub first_user_id: Uuid,
|
||||
pub second_user_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Selectable, Identifiable)]
|
||||
#[diesel(table_name = crate::schema::messages)]
|
||||
#[diesel(check_for_backend(diesel::pg::Pg))]
|
||||
pub struct Message {
|
||||
pub id: Uuid,
|
||||
pub sender_id: Uuid,
|
||||
pub recipient_id: Uuid,
|
||||
pub content: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub chat_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Insertable)]
|
||||
#[diesel(table_name = crate::schema::messages)]
|
||||
pub struct NewMessage {
|
||||
pub sender_id: Uuid,
|
||||
pub recipient_id: Uuid,
|
||||
pub content: String,
|
||||
pub chat_id: Uuid,
|
||||
}
|
||||
60
ticktalk/crates/db/src/repositories/chat.rs
Executable file
60
ticktalk/crates/db/src/repositories/chat.rs
Executable file
@@ -0,0 +1,60 @@
|
||||
use crate::errors::RepositoryError;
|
||||
use crate::models::Chat;
|
||||
use crate::{DbPool, models::NewChat};
|
||||
use diesel::{BoolExpressionMethods, ExpressionMethods, QueryDsl, SelectableHelper};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ChatRepository {
|
||||
pool: DbPool,
|
||||
}
|
||||
|
||||
impl ChatRepository {
|
||||
pub fn new(pool: DbPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, chat_id: Uuid) -> Result<Option<Chat>, RepositoryError> {
|
||||
use crate::schema::chats::dsl::*;
|
||||
|
||||
let mut conn = self.pool.get().await?;
|
||||
|
||||
match chats
|
||||
.find(chat_id)
|
||||
.select(Chat::as_select())
|
||||
.first(&mut conn)
|
||||
.await
|
||||
{
|
||||
Ok(chat) => Ok(Some(chat)),
|
||||
Err(diesel::result::Error::NotFound) => Ok(None),
|
||||
Err(e) => Err(RepositoryError::Query(e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(&self, new_chat: NewChat) -> Result<Chat, RepositoryError> {
|
||||
use crate::schema::chats::dsl::*;
|
||||
|
||||
let mut conn = self.pool.get().await?;
|
||||
|
||||
diesel::insert_into(chats)
|
||||
.values(&new_chat)
|
||||
.returning(Chat::as_returning())
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
pub async fn find_by_user(&self, user: Uuid) -> Result<Vec<Chat>, RepositoryError> {
|
||||
use crate::schema::chats::dsl::*;
|
||||
|
||||
let mut conn = self.pool.get().await?;
|
||||
|
||||
chats
|
||||
.filter(first_user_id.eq(user).or(second_user_id.eq(user)))
|
||||
.select(Chat::as_select())
|
||||
.load(&mut conn)
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
}
|
||||
47
ticktalk/crates/db/src/repositories/message.rs
Executable file
47
ticktalk/crates/db/src/repositories/message.rs
Executable file
@@ -0,0 +1,47 @@
|
||||
use crate::{
|
||||
DbPool,
|
||||
errors::RepositoryError,
|
||||
models::{Message, NewMessage},
|
||||
};
|
||||
use diesel::SelectableHelper;
|
||||
use diesel::prelude::*;
|
||||
use diesel_async::RunQueryDsl;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MessageRepository {
|
||||
pool: DbPool,
|
||||
}
|
||||
|
||||
impl MessageRepository {
|
||||
pub fn new(pool: DbPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn create(&self, new_message: NewMessage) -> Result<Message, RepositoryError> {
|
||||
use crate::schema::messages::dsl::*;
|
||||
|
||||
let mut conn = self.pool.get().await?;
|
||||
|
||||
match diesel::insert_into(messages)
|
||||
.values(&new_message)
|
||||
.returning(Message::as_returning())
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
{
|
||||
Ok(message) => Ok(message),
|
||||
Err(e) => Err(RepositoryError::from(e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_chat_id(&self, c_id: Uuid) -> Result<Vec<Message>, RepositoryError> {
|
||||
use crate::schema::messages::dsl::*;
|
||||
|
||||
let mut conn = self.pool.get().await?;
|
||||
|
||||
match messages.filter(chat_id.eq(c_id)).load(&mut conn).await {
|
||||
Ok(chat_messages) => Ok(chat_messages),
|
||||
Err(e) => Err(RepositoryError::from(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
7
ticktalk/crates/db/src/repositories/mod.rs
Executable file
7
ticktalk/crates/db/src/repositories/mod.rs
Executable file
@@ -0,0 +1,7 @@
|
||||
mod chat;
|
||||
mod message;
|
||||
mod user;
|
||||
|
||||
pub use self::chat::ChatRepository;
|
||||
pub use self::message::MessageRepository;
|
||||
pub use self::user::UserRepository;
|
||||
85
ticktalk/crates/db/src/repositories/user.rs
Executable file
85
ticktalk/crates/db/src/repositories/user.rs
Executable file
@@ -0,0 +1,85 @@
|
||||
use crate::errors::RepositoryError;
|
||||
use crate::models::{NewUser, User};
|
||||
use crate::schema::users;
|
||||
use diesel::result::Error as DieselError;
|
||||
use diesel::{ExpressionMethods, QueryDsl, SelectableHelper};
|
||||
use diesel_async::RunQueryDsl;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbPool;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UserRepository {
|
||||
pool: DbPool,
|
||||
}
|
||||
|
||||
impl UserRepository {
|
||||
pub fn new(pool: DbPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, user_id: Uuid) -> Result<Option<User>, RepositoryError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
let mut conn = self.pool.get().await?;
|
||||
|
||||
match users
|
||||
.find(user_id)
|
||||
.select(User::as_select())
|
||||
.first(&mut conn)
|
||||
.await
|
||||
{
|
||||
Ok(user) => Ok(Some(user)),
|
||||
Err(diesel::result::Error::NotFound) => Ok(None),
|
||||
Err(e) => Err(RepositoryError::Query(e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_username(&self, user_name: String) -> Result<Option<User>, RepositoryError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
let mut conn = self.pool.get().await?;
|
||||
|
||||
match users
|
||||
.filter(username.eq(user_name))
|
||||
.select(User::as_select())
|
||||
.first(&mut conn)
|
||||
.await
|
||||
{
|
||||
Ok(user) => Ok(Some(user)),
|
||||
Err(diesel::result::Error::NotFound) => Ok(None),
|
||||
Err(e) => Err(RepositoryError::Query(e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(&self, user: NewUser) -> Result<User, RepositoryError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
let mut conn = self.pool.get().await?;
|
||||
|
||||
match diesel::insert_into(users)
|
||||
.values(&user)
|
||||
.returning(User::as_returning())
|
||||
.get_result(&mut conn)
|
||||
.await
|
||||
{
|
||||
Ok(created) => Ok(created),
|
||||
Err(DieselError::DatabaseError(
|
||||
diesel::result::DatabaseErrorKind::UniqueViolation,
|
||||
ref info,
|
||||
)) => {
|
||||
// Проверяем, какое именно ограничение нарушено
|
||||
let constraint_name = info.constraint_name();
|
||||
|
||||
let error_message = if constraint_name == Some("users_username_key") {
|
||||
"Пользователь с таким username уже существует".to_string()
|
||||
} else if constraint_name == Some("users_email_key") {
|
||||
"Пользователь с таким email уже существует".to_string()
|
||||
} else {
|
||||
"Нарушено ограничение уникальности".to_string()
|
||||
};
|
||||
|
||||
Err(RepositoryError::Conflict)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
33
ticktalk/crates/db/src/schema.rs
Executable file
33
ticktalk/crates/db/src/schema.rs
Executable file
@@ -0,0 +1,33 @@
|
||||
// @generated automatically by Diesel CLI.
|
||||
|
||||
diesel::table! {
|
||||
chats (id) {
|
||||
id -> Uuid,
|
||||
first_user_id -> Uuid,
|
||||
second_user_id -> Uuid,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
messages (id) {
|
||||
id -> Uuid,
|
||||
sender_id -> Uuid,
|
||||
recipient_id -> Uuid,
|
||||
content -> Text,
|
||||
created_at -> Timestamp,
|
||||
chat_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
users (id) {
|
||||
id -> Uuid,
|
||||
username -> Text,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::joinable!(messages -> chats (chat_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(chats, messages, users,);
|
||||
10
ticktalk/crates/types/Cargo.toml
Executable file
10
ticktalk/crates/types/Cargo.toml
Executable file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "ticktalk-types"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
# ticktalk-db = { path = "../db" }
|
||||
54
ticktalk/crates/types/src/lib.rs
Executable file
54
ticktalk/crates/types/src/lib.rs
Executable file
@@ -0,0 +1,54 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ChatResponse {
|
||||
pub id: Uuid,
|
||||
pub first_user_id: Uuid,
|
||||
pub second_user_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageResponse {
|
||||
pub sender_id: Uuid,
|
||||
pub recipient_id: Uuid,
|
||||
pub content: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateChatRequest {
|
||||
pub first_user_id: Uuid,
|
||||
pub second_user_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateMessageRequest {
|
||||
pub chat_id: Uuid,
|
||||
pub sender_id: Uuid,
|
||||
pub recipient_id: Uuid,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub ticket: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub session_id: Uuid,
|
||||
pub user: UserResponse,
|
||||
}
|
||||
4
ticktalk/docker/.env
Executable file
4
ticktalk/docker/.env
Executable file
@@ -0,0 +1,4 @@
|
||||
POSTGRES_USER=ticktalk_user
|
||||
POSTGRES_PASSWORD=ticktack
|
||||
POSTGRES_DB=ticktalk_db
|
||||
TEAM_TOKEN=7ajI7AlyA5Lu9KKA
|
||||
37
ticktalk/docker/backend/Dockerfile
Executable file
37
ticktalk/docker/backend/Dockerfile
Executable file
@@ -0,0 +1,37 @@
|
||||
FROM lukemathwalker/cargo-chef:latest-rust-1.91.1-slim-trixie AS chef
|
||||
WORKDIR /app
|
||||
|
||||
FROM chef AS planner
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/ ./crates/
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM chef AS builder
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN cargo chef cook --release --recipe-path recipe.json
|
||||
RUN cargo install diesel_cli --no-default-features --features postgres
|
||||
|
||||
COPY . .
|
||||
RUN cargo build --release --bin ticktalk-backend
|
||||
|
||||
FROM ubuntu:24.04 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY docker/backend/entrypoint.sh .
|
||||
COPY --from=builder /app/target/release/ticktalk-backend /usr/local/bin/backend
|
||||
COPY --from=builder /usr/local/cargo/bin/diesel ./diesel
|
||||
COPY --chmod=755 client/kauth /usr/local/bin/kauth
|
||||
COPY crates/db/migrations ./migrations
|
||||
COPY crates/db/diesel.toml .
|
||||
|
||||
RUN apt-get update && apt install -y \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& chmod +x ./entrypoint.sh
|
||||
|
||||
ENTRYPOINT [ "./entrypoint.sh" ]
|
||||
4
ticktalk/docker/backend/entrypoint.sh
Executable file
4
ticktalk/docker/backend/entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
|
||||
./diesel setup || exit 1
|
||||
exec /usr/local/bin/backend
|
||||
8
ticktalk/docker/cleaner/Dockerfile
Executable file
8
ticktalk/docker/cleaner/Dockerfile
Executable file
@@ -0,0 +1,8 @@
|
||||
FROM ubuntu:24.04
|
||||
RUN apt update && apt install -y postgresql-client && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY ./cleaner.sh /cleaner.sh
|
||||
RUN chmod +x /cleaner.sh
|
||||
|
||||
ENTRYPOINT [ "/cleaner.sh" ]
|
||||
28
ticktalk/docker/cleaner/cleaner.sh
Executable file
28
ticktalk/docker/cleaner/cleaner.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DIR="/caches"
|
||||
|
||||
while true; do
|
||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "[$TIMESTAMP] Starting cleanup"
|
||||
|
||||
if [ -d "${DIR}" ]; then
|
||||
find "${DIR}" -type f -mmin +6 -delete 2>/dev/null
|
||||
fi
|
||||
|
||||
if [ -n "${DATABASE_URL}" ]; then
|
||||
psql "${DATABASE_URL}" -v "ON_ERROR_STOP=1" <<'SQL'
|
||||
BEGIN;
|
||||
DELETE FROM chats WHERE created_at <= NOW() - INTERVAL '6 minutes';
|
||||
DELETE FROM messages WHERE created_at <= NOW() - INTERVAL '6 minutes';
|
||||
DELETE FROM users WHERE created_at <= NOW() - INTERVAL '6 minutes';
|
||||
COMMIT;
|
||||
SQL
|
||||
else
|
||||
echo " DATABASE_URL not set, skipping DB cleanup"
|
||||
fi
|
||||
|
||||
echo "[$TIMESTAMP] Cleanup completed"
|
||||
sleep 60
|
||||
done
|
||||
17
ticktalk/docker/kdc/Dockerfile
Executable file
17
ticktalk/docker/kdc/Dockerfile
Executable file
@@ -0,0 +1,17 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
g++ \
|
||||
libssl-dev \
|
||||
libboost-system-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . /app/
|
||||
|
||||
RUN mkdir -p /var/log && mkdir -p /app/data
|
||||
|
||||
EXPOSE 88
|
||||
|
||||
CMD ["./kerberos_kdc"]
|
||||
17
ticktalk/docker/kdc/config.json
Executable file
17
ticktalk/docker/kdc/config.json
Executable file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"kdc": {
|
||||
"port": 88,
|
||||
"thread_pool_size": 10,
|
||||
"realm": "TICKTALK.LOCAL",
|
||||
"team_id": "2",
|
||||
"principals_file": "/app/data/principals.json",
|
||||
"krbtgt_key_file": "/app/data/krbtgt_key.txt"
|
||||
},
|
||||
"redis": {
|
||||
"host": "redis",
|
||||
"port": 6379
|
||||
},
|
||||
"logging": {
|
||||
"levels": "INFO,WARNING,ERROR,DEBUG"
|
||||
}
|
||||
}
|
||||
BIN
ticktalk/docker/kdc/kerberos_kdc
Executable file
BIN
ticktalk/docker/kdc/kerberos_kdc
Executable file
Binary file not shown.
Reference in New Issue
Block a user