This commit is contained in:
root
2025-12-14 10:39:18 +03:00
commit 639f4e2b4e
179 changed files with 21065 additions and 0 deletions

1
ticktalk/.dockerignore Executable file
View File

@@ -0,0 +1 @@
Dockerfile

4
ticktalk/.env Executable file
View 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

File diff suppressed because it is too large Load Diff

9
ticktalk/Cargo.toml Executable file
View 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
View 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

Binary file not shown.

BIN
ticktalk/client/kdestroy Executable file

Binary file not shown.

BIN
ticktalk/client/kinit Executable file

Binary file not shown.

BIN
ticktalk/client/klist Executable file

Binary file not shown.

BIN
ticktalk/client/kvno Executable file

Binary file not shown.

BIN
ticktalk/client/ticktalk.appimage Executable file

Binary file not shown.

99
ticktalk/compose.yml Executable file
View 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
View File

@@ -0,0 +1 @@
/target

7
ticktalk/crates/backend/Cargo.lock generated Executable file
View 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"

View 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"

View 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
}))
}
}
}
}

View 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
})
}

View File

@@ -0,0 +1 @@
pub mod http;

View 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)
}

View 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
}

View 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)),
);
}

View 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,
})
}
}

View 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,
}
}

View 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::*;

View 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),
}
}
}

View 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,
})
}

View File

@@ -0,0 +1 @@

1
ticktalk/crates/db/.env Executable file
View File

@@ -0,0 +1 @@
DATABASE_URL=postgres://ticktalk_user:ticktack@localhost:5432/ticktalk_db

15
ticktalk/crates/db/Cargo.toml Executable file
View 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
View 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"

View File

View File

View 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();

View 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;

View File

@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS chats;
DROP TABLE IF EXISTS users;

View 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);

View 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
View 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")
}

View 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,
}

View 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())
}
}

View 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)),
}
}
}

View 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;

View 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()),
}
}
}

View 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,);

View 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" }

View 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
View File

@@ -0,0 +1,4 @@
POSTGRES_USER=ticktalk_user
POSTGRES_PASSWORD=ticktack
POSTGRES_DB=ticktalk_db
TEAM_TOKEN=7ajI7AlyA5Lu9KKA

View 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" ]

View File

@@ -0,0 +1,4 @@
#!/bin/sh
./diesel setup || exit 1
exec /usr/local/bin/backend

View 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" ]

View 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
View 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
View 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

Binary file not shown.