Ogonek

As a teacher, I look to improve my students' experience daily. Digitalisation and automation are part of the deal. There are many classroom management solutions for classes, but none – for private teachers like me.

See for yourself View the code
Description of image 1

Dashboard

1 / 5

Ogonek – The Digital Classroom

Overview

Ogonek is a self-hosted digital classroom platform that transforms private teaching through a robust Rust backend and reactive Svelte frontend. It provides a structured environment for lesson management, assignment tracking, and secure teacher-student interactions, built with Axum, SQLx, and PostgreSQL.

Architecture

Backend (Rust)

  • Framework: Axum for high-performance API endpoints
  • Database Access: SQLx for type-safe database operations
  • Role System: JWT-based authentication with role-based access control
  • File Handling: S3 integration to handle files
  • Notifications: Integrated Telegram API for alerts

Frontend (Svelte)

  • Server-Side Rendering: Enhanced security and performance
  • Routing: Dynamic path handling based on user roles
  • State Management: Custom stores for real-time updates
  • Forms: Server-side validation and secure input handling

Database Schema

  • Primary Store: PostgreSQL with optimized indexes
  • Key Models:
    • Users (Teachers/Students)
    • Lessons
    • Tasks
    • Files
    • Relationships

Infrastructure

  • Containerization: Multi-stage Docker builds for optimal image sizes
  • Container Registry: GitHub Packages for version control
  • Deployment: VPS with Docker Compose orchestration
  • SSL/TLS: Automated certificate management
  • Backup Strategy: Automated PostgreSQL dumps with retention policies

Database Schema

CREATE TABLE teacher_student (
    teacher_id VARCHAR(21) REFERENCES "user"(id) ON DELETE CASCADE,
    student_id VARCHAR(21) REFERENCES "user"(id) ON DELETE CASCADE,
    status VARCHAR(20) DEFAULT 'active',
    markdown TEXT,
    telegram_id VARCHAR(20),
    joined TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (teacher_id, student_id)
);

CREATE INDEX idx_teacher_student_notes
    ON teacher_student
    USING GIN (to_tsvector('english', markdown));

Technical Challenges & Solutions

1. Role-Based Access Control

  • Challenge: Implementing secure, granular access control
  • Solution: Multi-layered security approach
// Svelte route guard implementation
if (user) {
	const isTeacherRoute = role === 't';
	const isStudentRoute = role === 's';

	if (isTeacherRoute && user.role !== 'teacher') {
		throw redirect(303, '/unauthorised');
	}

	if (isStudentRoute && user.role !== 'student') {
		throw redirect(303, '/unauthorised');
	}
}

2. File Management System

  • Challenge: Secure, scalable file handling
  • Solution: Dedicated microservice architecture
pub async fn upload_handler(mut multipart: Multipart) -> Result<impl IntoResponse, StatusCode> {
    let upload_path = std::env::var("UPLOAD_PATH")
        .unwrap_or_else(|_| "./uploads".to_string());

    if !PathBuf::from(&upload_path).exists() {
        fs::create_dir_all(&upload_path).await.map_err(|err| {
            error!("Failed to create upload directory: {:?}", err);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;
    }

    let mut unique_filename = String::new();

    while let Some(mut field) = multipart.next_field().await.map_err(|err| {
        error!("Error processing multipart field: {:?}", err);
        StatusCode::BAD_REQUEST
    })? {
        let filename = field
            .file_name()
            .ok_or_else(|| {
                error!("Field missing filename");
                StatusCode::BAD_REQUEST
            })?
            .to_string();

        let safe_filename = filename
            .trim()
            .to_lowercase()
            .replace(' ', "-")
            .chars()
            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '.')
            .collect::<String>();

        unique_filename = format!("{}-{}", uuid::Uuid::new_v4(), safe_filename);
        let file_path = PathBuf::from(&upload_path).join(&unique_filename);

        let mut file = tokio::fs::File::create(&file_path).await.map_err(|err| {
            error!("Failed to create file: {:?}", err);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

        while let Some(chunk) = field.chunk().await.map_err(|err| {
            error!("Failed to read chunk: {:?}", err);
            StatusCode::INTERNAL_SERVER_ERROR
        })? {
            file.write_all(&chunk).await.map_err(|err| {
                error!("Failed to write chunk: {:?}", err);
                StatusCode::INTERNAL_SERVER_ERROR
            })?;
        }

        info!("Uploaded file: {}", safe_filename);
    }

    Ok(Json(unique_filename))
}

3. Real-Time Notifications

  • Challenge: Reliable notification delivery to the student
  • Solution: Queue-based notification system with Telegram integration
export async function notifyTelegram(message: string, addressee: string): Promise<Response> {
	if (!env.TELEGRAM_API) {
		console.error('Telegram API token not configured');
		return new Response(JSON.stringify({ message: 'Telegram configuration missing' }), {
			status: 500
		});
	}

	try {
		const response = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_API}/sendMessage`, {
			method: 'POST',
			headers: {
				'Content-Type': 'application/json'
			},
			body: JSON.stringify({
				chat_id: addressee,
				text: message,
				parse_mode: 'MarkdownV2'
			})
		});

		const data = (await response.json()) as TelegramResponse;

		if (!response.ok) {
			console.error('Telegram API error:', data.description);
			return new Response(
				JSON.stringify({
					message: 'Failed to send message',
					error: data.description
				}),
				{ status: 404 }
			);
		}

		return new Response(JSON.stringify({ message: 'Message sent successfully' }), { status: 200 });
	} catch (error) {
		console.error('Failed to send Telegram message:', error);
		return new Response(
			JSON.stringify({
				message: 'Internal server error',
				error: error instanceof Error ? error.message : 'Unknown error'
			}),
			{ status: 500 }
		);
	}
}

Authentication Flow

impl<S> FromRequestParts<S> for RefreshClaims
where
    S: Send + Sync,
{
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let cookies = parts
            .extract::<TypedHeader<Cookie>>()
            .await
            .map_err(|_| AuthError::InvalidToken)?;

        let refresh_token = cookies
            .get("refreshToken")
            .ok_or(AuthError::InvalidToken)?
            .to_string();

        let validation = Validation::new(Algorithm::RS256);
        let token_data = decode::<RefreshClaims>(
            &refresh_token,
            &KEYS_REFRESH.decoding,
            &validation
        ).map_err(|e| {
            eprintln!("Token extraction error: {:?}", e);
            AuthError::InvalidToken
        })?;

        Ok(token_data.claims)
    }
}

Error Tracking (simplified)

#[derive(Debug, Error)]
pub enum AppError {
    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),

    #[error("Authentication failed: {0}")]
    Auth(String),

    #[error("File operation failed: {0}")]
    FileOp(String),
}

Development Pipeline (one element)

axum:
  build:
    context: ./axum
  dockerfile: Dockerfile.dev
  container_name: axum-fl-dev
  ports:
    - '8000:3000'
  volumes:
    - ./axum:/app
    - cargo-cache:/usr/local/cargo/registry
    - cargo-target:/app/target
  env_file:
    - ./axum/.env
  depends_on:
    postgres:
      condition: service_healthy

Fazit

This implementation showcases how modern web technologies can be leveraged to create a secure, efficient, and user-friendly educational platform. The combination of Rust's performance, Svelte's reactivity, and careful architectural decisions results in a robust system that effectively serves both teachers and students.

Let's get something done