This commit is contained in:
sinavir 2024-10-10 01:38:42 +02:00
parent 331971da81
commit 3d1673d3f5
46 changed files with 5395 additions and 0 deletions

28
api-docs/index.html Normal file
View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<title>Swagger</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="//unpkg.com/swagger-ui-dist@3/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="//unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
<script>
const ui = SwaggerUIBundle({
url: "/api-docs/openapi.json",
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout",
requestInterceptor: (request) => {
request.headers['X-CSRFToken'] = "{{ csrf_token }}"
return request;
}
})
</script>
</body>
</html>

194
api-docs/openapi.json Normal file
View file

@ -0,0 +1,194 @@
{
"openapi": "3.0.0",
"info": {
"version": "1.0.0",
"title": "R/AGB",
"description": "Une API pour choisir les couleurs en agb. Il existe un endpoint SSE pour être notifié des mises à jour des couleurs à l'addresse [https://agb.hackens.org/api/sse](https://agb.hackens.org/api/sse). Une description du plan d'addressage au format JSON est disponible: [/api-docs/patch.json](/api-docs/patch.json). On peut récupérer le token d'API à l'addresse: [/token](/token)."
},
"servers": [
{
"url": "https://agb.hackens.org/api"
}
],
"paths": {
"/values": {
"get": {
"summary": "Get the colors for all the channels",
"operationId": "getValues",
"responses": {
"200": {
"description": "List of colors",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Color"
}
}
}
}
}
}
},
"post": {
"summary": "Change multiple colors",
"description": "Change multiple colors. This request has a cooldown of 500ms.",
"operationId": "changeColors",
"security": [
{
"bearerAuth": [
"exp",
"scope",
"user",
"sub"
]
}
],
"requestBody": {
"description": "Colors to change",
"required": true,
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"address": {
"type": "integer",
"format": "int64"
},
"value": {
"$ref": "#/components/schemas/Color"
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "Color updated successfully",
"content": {
}
},
"429": {
"description": "You're being rate-limited",
"content": {
}
}
}
}
},
"/values/{id}": {
"get": {
"summary": "Get channel colors by ID",
"operationId": "find color by id",
"parameters": [
{
"name": "id",
"in": "path",
"description": "ID of color to fetch",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "Color of specific channel",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Color"
}
}
}
}
}
},
"post": {
"summary": "Changes channel color",
"description": "Changes channel color",
"operationId": "changeColor",
"parameters": [
{
"name": "id",
"in": "path",
"description": "ID of color to fetch",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"security": [
{
"bearerAuth": [
"exp",
"scope",
"user",
"sub"
]
}
],
"requestBody": {
"description": "Color to change",
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Color"
}
}
}
},
"responses": {
"200": {
"description": "Color updated successfully",
"content": {
}
}
}
}
}
},
"components": {
"schemas": {
"Color": {
"type": "object",
"required": [
"red",
"green",
"blue"
],
"properties": {
"red": {
"type": "integer",
"format": "int8"
},
"green": {
"type": "integer",
"format": "int8"
},
"blue": {
"type": "integer",
"format": "int8"
}
}
}
},
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
}
}

1651
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

21
backend/Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "ragb-backend"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = { version = "0.7.7", features = ["macros"] }
axum-extra = { version = "0.9.4", features = ["typed-header"] }
dotenvy = "0.15.7"
jsonwebtoken = "9.3.0"
serde = { version = "1.0.210", features = ["derive"] }
serde_json = "1.0.128"
tokio = { version = "1.40.0", features = ["full"] }
tokio-stream = { version = "0.1.16", features = ["sync"] }
tower = "0.5.1"
tower-http = { version = "0.6.1", features = ["tracing", "trace", "cors"] }
tower_governor = { version = "0.4.2", features = ["axum"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }

1
backend/README.md Normal file
View file

@ -0,0 +1 @@
This is the backend of ragb. It provides a simple CRUD with in memory database.

2
backend/auth_curl.sh Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env bash
curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyYWdiIiwic2NvcGUiOiJtb2RpZnkiLCJleHAiOjE3Mzk0ODY3MDd9.J8Qh12hxQ9rZFKhpjcth2uUrEhnFVH8PAVmkVgM1wIo" $@

View file

@ -0,0 +1,56 @@
use crate::model::DB;
use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::Response,
};
use axum_extra::{headers, typed_header::TypedHeader};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
scope: String,
user: User,
}
#[derive(Eq, PartialEq, Hash, Debug, Serialize, Deserialize, Clone)]
pub struct User(String);
fn check_token(token: &str, jwt: &str) -> Option<User> {
let decoded_token = decode::<Claims>(
&token,
&DecodingKey::from_secret(jwt.as_bytes()),
&Validation::new(Algorithm::HS256),
);
match decoded_token {
Ok(token_data) => {
let user =token_data.claims.user;
if token_data.claims.scope == "modify" {
tracing::info!("Successful auth {user:?}");
Some(user)
} else {
tracing::debug!("Failed auth: {user:?} don't have modify scope");
None
}
}
Err(err) => {tracing::debug!("Failed decoding token: {err:?}"); None},
}
}
pub async fn jwt_middleware(
State(state): State<DB>,
TypedHeader(auth): TypedHeader<headers::Authorization<headers::authorization::Bearer>>,
mut request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token = auth.token();
if let Some(user) = check_token(token, &state.static_state.jwt_key) {
request.extensions_mut().insert(user);
Ok(next.run(request).await)
} else {
Err(StatusCode::FORBIDDEN)
}
}

118
backend/src/handler.rs Normal file
View file

@ -0,0 +1,118 @@
use crate::authorization::User;
use crate::model::{DMXArray, DMXAtom, DMXColor, DB};
use axum::{
debug_handler,
extract::{Path, State},
http::StatusCode,
response::{sse::Event, IntoResponse, Sse},
Extension, Json,
};
use std::time::{Duration, Instant};
use tokio_stream::StreamExt;
use tokio_stream::{self as stream};
#[debug_handler]
pub async fn healthcheck_handler() -> impl IntoResponse {
StatusCode::OK
}
#[debug_handler]
pub async fn list_values_handler(State(db): State<DB>) -> impl IntoResponse {
let val;
{
let lock = db.mut_state.read().await;
val = lock.dmx.clone();
}
Json(val)
}
#[debug_handler]
pub async fn batch_edit_value_handler(
State(db): State<DB>,
Extension(user): Extension<User>,
Json(body): Json<Vec<DMXAtom>>,
) -> Result<(), StatusCode> {
let mut lock = db.mut_state.write().await;
if lock.ratelimit_info.get(&user).map(|d| d.elapsed() <= Duration::from_millis(500)).unwrap_or(false){
return Err(StatusCode::TOO_MANY_REQUESTS);
}
for i in &body {
check_id(i.address, &lock.dmx)?;
}
for i in &body {
lock.dmx[i.address] = i.value;
match db.static_state.color_change_channel.send(*i) {
Ok(_) => (),
Err(_) => (),
}
}
lock.ratelimit_info.insert(user, Instant::now());
Ok(())
}
#[debug_handler]
pub async fn get_value_handler(
Path(id): Path<usize>,
State(db): State<DB>,
) -> Result<impl IntoResponse, StatusCode> {
let lock = db.mut_state.read().await;
check_id(id, &lock.dmx)?;
Ok(Json(lock.dmx[id]))
}
#[debug_handler]
pub async fn edit_value_handler(
Path(id): Path<usize>,
State(db): State<DB>,
Json(body): Json<DMXColor>,
) -> Result<(), StatusCode> {
let mut lock = db.mut_state.write().await;
check_id(id, &lock.dmx)?;
lock.dmx[id] = body;
match db
.static_state
.color_change_channel
.send(DMXAtom::new(id, body))
{
Ok(_) => (),
Err(_) => (),
};
Ok(())
}
#[debug_handler]
pub async fn sse_handler(State(db): State<DB>) -> impl IntoResponse {
let rx = db.static_state.color_change_channel.subscribe();
let data: Vec<DMXColor>;
{
let lock = db.mut_state.read().await;
data = lock.dmx.clone();
}
let init_data = data.into_iter().enumerate().map(|(i, x)| {
Ok(DMXAtom {
address: i,
value: x,
})
});
let init = stream::iter(init_data);
let stream = init
.chain(stream::wrappers::BroadcastStream::new(rx))
.filter_map(|item| match item {
Ok(val) => Some(Event::default().json_data(val)),
Err(_) => None,
});
Sse::new(stream).keep_alive(
axum::response::sse::KeepAlive::new()
.interval(Duration::from_secs(25))
.text("ping"),
)
}
fn check_id(id: usize, val: &DMXArray) -> Result<(), StatusCode> {
if id >= val.len() {
return Err(StatusCode::NOT_FOUND);
};
Ok(())
}

36
backend/src/main.rs Normal file
View file

@ -0,0 +1,36 @@
mod authorization;
mod handler;
mod model;
mod route;
use dotenvy;
use route::create_router;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use tower_http::trace::TraceLayer;
#[tokio::main]
async fn main() {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
"info,axum::rejection=trace".into()
}),
)
.with(tracing_subscriber::fmt::layer())
.init();
match dotenvy::dotenv() {
Err(_) => tracing::info!(".env file not found"),
_ => (),
}
let binding = dotenvy::var("BIND").unwrap_or(String::from("127.0.0.1:9999"));
let bind = binding.trim();
tracing::debug!("Trying to bind at {bind}");
let listener = tokio::net::TcpListener::bind(bind).await.unwrap();
let app = create_router()
.layer(
TraceLayer::new_for_http()
);
tracing::info!("🚀 Server started successfully");
axum::serve(listener, app).await.unwrap();
}

108
backend/src/model.rs Normal file
View file

@ -0,0 +1,108 @@
use crate::authorization::User;
use dotenvy;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{broadcast, RwLock};
#[derive(Debug, Deserialize, Serialize, Copy, Clone)]
pub struct DMXColor {
pub red: u8,
pub green: u8,
pub blue: u8,
}
pub type DMXArray = Vec<DMXColor>;
#[derive(Debug, Deserialize, Serialize, Copy, Clone)]
pub struct DMXAtom {
pub address: usize,
pub value: DMXColor,
}
impl DMXAtom {
pub fn new(address: usize, value: DMXColor) -> DMXAtom {
DMXAtom { address, value }
}
}
pub struct AppState {
pub dmx: DMXArray,
pub ratelimit_info: HashMap<User, Instant>,
}
impl AppState {
pub fn new(size: usize, save_path: &str) -> AppState {
let data: Result<DMXArray, ()> =
match fs::File::open(save_path).map(std::io::BufReader::new) {
Ok(read) => serde_json::from_reader(read)
.map(|mut v: DMXArray| {
v.resize(
size,
DMXColor {
red: 0,
green: 0,
blue: 0,
},
);
v
})
.map_err(|e| {
tracing::debug!("Error loading data: {e}");
}),
Err(e) => {
tracing::debug!("Error loading data: {e}");
Err(())
}
};
let dmx = data.unwrap_or(vec![
DMXColor {
red: 0,
green: 0,
blue: 0,
};
size
]);
AppState {
dmx,
ratelimit_info: HashMap::new(),
}
}
}
pub struct StaticState {
pub jwt_key: String,
pub color_change_channel: broadcast::Sender<DMXAtom>,
pub save_path: String,
}
pub struct SharedState {
pub static_state: StaticState,
pub mut_state: RwLock<AppState>,
}
pub type DB = Arc<SharedState>;
pub fn make_db() -> DB {
let state_size: usize =
usize::from_str(&dotenvy::var("NB_DMX_VALUES").unwrap_or(String::from("100"))).unwrap();
let save_path = dotenvy::var("BK_FILE").unwrap_or(String::from("data.json"));
let mut_state = RwLock::new(AppState::new(state_size, &save_path));
Arc::new(SharedState {
static_state: StaticState {
jwt_key: serde_json::from_str(
dotenvy::var("JWT_SECRET")
.unwrap_or(String::from("\"secret\""))
.as_ref(),
)
.expect("an JSON string"),
color_change_channel: broadcast::Sender::new(512),
save_path,
},
mut_state,
})
}

65
backend/src/route.rs Normal file
View file

@ -0,0 +1,65 @@
use crate::authorization::jwt_middleware;
use crate::handler;
use crate::model;
use axum::{handler::Handler, middleware};
use axum::{routing::get, Router};
use serde_json::to_writer;
use std::fs::File;
use tokio::task;
use tokio::time::{sleep, Duration};
use tower_http::cors::{Any, CorsLayer};
pub fn create_router() -> Router {
let db = model::make_db();
let cors = CorsLayer::new()
// allow requests from any origin
.allow_origin(Any)
.allow_headers(Any);
let db_to_save = db.clone();
task::spawn(async move {
loop {
sleep(Duration::from_millis(1000)).await;
{
let save_path = &db_to_save.static_state.save_path;
let file = File::create(save_path);
match file {
Ok(f) => {
let db = db_to_save.mut_state.read().await;
match to_writer(f, &db.dmx) {
Ok(()) => tracing::trace!("Saved data at {save_path}"),
Err(e) => {
tracing::debug!("Failed to save data: {e:?}");
()
}
}
}
Err(e) => {
tracing::debug!("Failed to save data: {e:?}");
()
}
}
}
}
});
Router::new()
.route("/api/healthcheck", get(handler::healthcheck_handler))
.route(
"/api/values",
get(handler::list_values_handler).post(
handler::batch_edit_value_handler
.layer(middleware::from_fn_with_state(db.clone(), jwt_middleware)),
),
)
.route("/api/sse", get(handler::sse_handler))
.route(
"/api/values/:id",
get(handler::get_value_handler).post(
handler::edit_value_handler
.layer(middleware::from_fn_with_state(db.clone(), jwt_middleware)),
),
)
.layer(cors)
.with_state(db)
}

View file

View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class FrontendConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "frontend"

View file

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View file

@ -0,0 +1,116 @@
function hexToRgb(hex) {
let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
function componentToHex(c) {
var hex = c.toString(16);
return hex.length == 1 ? "0" + hex : hex;
}
function rgbToHex({red, green, blue}) {
return "#" + componentToHex(red) + componentToHex(green) + componentToHex(blue);
}
let startSocket = () => {
const socket = new EventSource(`${WEBSOCKET_ENDPOINT}/sse`);
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
const color = rgbToHex(message.value);
console.log(color, `light${message.address}`);
const element = document.getElementById(`light${message.address}`);
if(element !== null) {
element.style.fill = color;
}
const inputElement = document.getElementById(`input-light${message.address}`);
if(inputElement !== null) {
inputElement.value = color;
}
});
socket.addEventListener("error", (event) => {
console.error(event);
startSocket();
});
};
document.addEventListener('DOMContentLoaded', startSocket);
document.addEventListener('DOMContentLoaded', () => {
// Functions to open and close a modal
function openModal($el) {
$el.showModal();
}
function closeModal($el) {
$el.close();
}
function closeAllModals() {
(document.querySelectorAll('dialog') || []).forEach(($modal) => {
closeModal($modal);
});
}
const $loadingModal = document.getElementById("modal-loading");
closeModal($loadingModal);
// Add a click event on buttons to open a specific modal
(document.querySelectorAll('.modal-trigger') || []).forEach(($trigger) => {
const modal = $trigger.dataset.target;
const $target = document.getElementById(modal);
if ($target !== null) $trigger.addEventListener('click', () => {
openModal($target);
});
});
// Add a click event on various child elements to close the parent modal
(document.querySelectorAll('.close-modal') || []).forEach(($close) => {
const $target = $close.closest('dialog');
$close.addEventListener('click', () => {
closeModal($target);
});
});
// Add a click event to save colors
(document.querySelectorAll('.save-color') || []).forEach(($trigger) => {
const lightId = $trigger.dataset.target;
const $input = document.getElementById(`input-light${lightId}`);
const url = `${WEBSOCKET_ENDPOINT}/values/${lightId}`;
$trigger.addEventListener('click', async (e) => {
e.preventDefault();
const color = hexToRgb($input.value);
openModal($loadingModal)
await fetch(url, {
method: 'POST',
//mode: "no-cors",
headers: {
'Accept': "application/json",
'Content-Type': "application/json",
'Authorization': `Bearer ${JWT}`
},
body: JSON.stringify({"red": color.r, "green": color.g, "blue": color.b}),
}).then((resp) => {
if(!resp.ok) {
alert(`Request failed. Err code:${resp.status}`);
}
}).catch((e) => {
alert(`Request failed. Err: ${e}`);
console.log(e);
}).finally(() => {
closeAllModals();
});
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512H418.3c16.4 0 29.7-13.3 29.7-29.7C448 383.8 368.2 304 269.7 304H178.3z"/></svg>

After

Width:  |  Height:  |  Size: 410 B

View file

@ -0,0 +1,60 @@
{% load static %}
<!doctype html>
<html data-theme="light" lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark" />
<link rel="stylesheet" href="{% static "css/pico.pumpkin.min.css" %}">
<title>R/AGB</title>
<link rel="shortcut icon" type="image/png" href="{% static 'media/logo.png' %}">
</head>
<body>
<header class="container">
<nav>
<ul>
<li>
<a aria-label="Page d'accueil" href="{% url "frontend:home" %}">
<img style="height:50px;" src="{% static "media/logo.png" %}" alt="Logo d'hackens"/>
</a>
</li>
</ul>
<ul>
<li class="hide-before-sm">
<a class="contrast" href="{% url "frontend:home" %}">Accueil</a>
</li>
<li class="hide-before-sm">
<a class="contrast" href="/api-docs/">API</a>
</li>
{% if user.is_staff %}
<li class="hide-before-sm">
<a class="contrast" href="{% url "admin:index" %}">Admin</a>
</li>
{% endif %}
{% if user.is_authenticated %}
<li>
<a href="{% url "authens:logout" %}">
<strong>Déconnexion</strong>
</a>
</li>
<li>
<button>
🧑&nbsp;{{ user.username }}
</button>
</li>
{% else %}
<li>
<a href="{% url "authens:login" %}">
Connexion
</a>
</li>
{% endif %}
</ul>
</nav>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,63 @@
{% extends "frontend/base.html" %}
{% load static %}
{% block content %}
{% if not user.is_authenticated %}
<article>
<p>
Connectez-vous pour pouvoir modifier des couleurs
</p>
</article>
{% endif %}
<section>
<h1>{{ light_name }}</h1>
<figure>
<svg viewBox="-3.5 -3.5 112 112" version="1.1" id="blinder" >
<style>
circle {
fill: white;
stroke: black;
stroke-width: 2;
}
{% if user.is_authenticated %}
circle:hover {
stroke: grey;
}
{% endif %}
</style>
<g id="layer1">
<rect style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" width="110" height="110" x="-2.5" y="-2.5" rx="15" />
{% for light in lights %}
<circle id="light{{ light.id }}" r="10" cx="{{ light.position_x }}" cy="{{ light.position_y }}" data-target="modal-light{{ light.id }}" class="modal-trigger"/>
{% endfor %}
</g>
</svg>
</figure>
</section>
{% if user.is_authenticated %}
{% for light in lights %}
<dialog id="modal-light{{light.id}}">
<article>
<header>
<button class="close-modal" rel="prev" aria-label="close"></button>
<h3>Couleur du pixel n°{{light.id}}</h3>
</header>
<section>
<form>
<input id="input-light{{light.id}}" class="input" type="color" name="light{{light.id}}">
<button class="save-color" data-target="{{light.id}}">Enregistrer</button>
</form>
</section>
</article>
</dialog>
{% endfor %}
{% endif %}
<dialog open id="modal-loading">
<article aria-busy="true"></article>
</dialog>
<script>const WEBSOCKET_ENDPOINT = "{{ websocket_endpoint }}";</script>
{% if user.is_authenticated %}
<script>const JWT = "{{ jwt }}";</script>
{% endif %}
<script src="{% static "js/update-color.js" %}" defer></script>
{% endblock %}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,54 @@
{% extends "frontend/base.html" %}
{% load static %}
{% block content %}
<section class="section content container is-max-desktop">
<h1 class="title is-3">{{ object.name }}</h1>
<svg viewBox="-1 -1 162 12" version="1.1" id="barreled" >
<style>
rect {
fill: white;
stroke: black;
stroke-width: 1;
}
{% if user.is_authenticated %}
rect:hover {
stroke: grey;
}
{% endif %}
</style>
<g id="layer1">
{% for light in lights %}
<rect id="light{{ light.id }}" width="10" height="10" x="{{ light.position }}" y="0" class="modal-trigger" data-target="modal-light{{ light.id }}" />
{% endfor %}
</g>
</svg>
</section>
{% if user.is_authenticated %}
{% for light in lights %}
<dialog id="modal-light{{light.id}}">
<article>
<header>
<button class="close-modal" rel="prev" aria-label="close"></button>
<h3>Couleur du pixel n°{{light.id}}</h3>
</header>
<section>
<form>
<input id="input-light{{light.id}}" class="input" type="color" name="light{{light.id}}">
<button class="save-color" data-target="{{light.id}}">Enregistrer</button>
</form>
</section>
</article>
</dialog>
{% endfor %}
{% endif %}
<dialog open id="modal-loading">
<article aria-busy="true"></article>
</dialog>
<script>const WEBSOCKET_ENDPOINT = "{{ websocket_endpoint }}";</script>
{% if user.is_authenticated %}
<script>const JWT = "{{ jwt }}";</script>
{% endif %}
<script src="{% static "js/update-color.js" %}" defer></script>
{% endblock %}

View file

@ -0,0 +1,29 @@
{% extends "frontend/base.html" %}
{% load static %}
{% block content %}
{% if not user.is_authenticated %}
<article>
<p>
Connectez-vous pour pouvoir modifier des couleurs
</p>
</article>
{% endif %}
<section>
<h1>{{ light_name }}</h1>
{% if user.is_authenticated %}
<form>
<input id="input-light{{lights.id}}" class="input" type="color" name="light{{lights.id}}">
<button class="save-color" data-target="{{lights.id}}">Enregistrer</button>
</form>
{% endif %}
</section>
<dialog open id="modal-loading">
<article aria-busy="true"></article>
</dialog>
<script>const WEBSOCKET_ENDPOINT = "{{ websocket_endpoint }}";</script>
{% if user.is_authenticated %}
<script>const JWT = "{{ jwt }}";</script>
{% endif %}
<script src="{% static "js/update-color.js" %}" defer></script>
{% endblock %}

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
frontend/frontend/urls.py Normal file
View file

@ -0,0 +1,10 @@
from django.urls import path
from .views import HomeView, LightView, TokenView
app_name = "frontend"
urlpatterns = [
path("", HomeView.as_view(), name="home"),
path("light/<str:light>/", LightView.as_view()),
path("token", TokenView.as_view()),
]

View file

@ -0,0 +1,95 @@
from datetime import datetime, timedelta, timezone
import jwt
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ViewDoesNotExist
from django.http import Http404, JsonResponse
from django.views import View
from django.views.generic.base import TemplateView
def get_context_from_proj(kind, chans):
print(kind, chans)
match kind:
case "blinder":
return [
{
"id": chans[i],
"position_y": ((15 - i) // 4) * 25 + 15,
"position_x": ((15 - i) % 4) * 25 + 15,
}
for i in range(len(chans))
]
case "led_tub":
return [
{
"id": chans[i],
"position": i * 10,
}
for i in range(len(chans))
]
case "spot":
return {
"id": chans[0],
}
case _:
raise ViewDoesNotExist()
class TokenView(LoginRequiredMixin, View):
def get(self, request, *arg, **kwargs):
return JsonResponse(
{
"token": jwt.encode(
{
"exp": datetime.now(tz=timezone.utc) + timedelta(hours=9),
"sub": "ragb",
"user": self.request.user.username,
"scope": "modify",
},
settings.JWT_SECRET,
)
}
)
class LightView(TemplateView):
def get_template_names(self):
lights = settings.LIGHTS["lights"][self.kwargs["light"]]
return [f"frontend/{lights['kind']}.html"]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.user.is_authenticated:
context["jwt"] = jwt.encode(
{
"exp": datetime.now(tz=timezone.utc) + timedelta(hours=9),
"sub": "ragb",
"user": self.request.user.username,
"scope": "modify",
},
settings.JWT_SECRET,
)
context["websocket_endpoint"] = settings.WEBSOCKET_ENDPOINT
light = self.kwargs["light"]
if light not in settings.LIGHTS["lights"]:
raise Http404("Light does not exist")
lights = settings.LIGHTS["lights"][light]
context["lights"] = get_context_from_proj(lights["kind"], lights["channels"])
context["light_name"] = lights["name"]
return context
class HomeView(TemplateView):
template_name = "frontend/home.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["websocket_endpoint"] = settings.WEBSOCKET_ENDPOINT
lights = settings.LIGHTS["lights"]
context["lights"] = lights
return context

22
frontend/manage.py Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ragb.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

49
frontend/patch.json Normal file
View file

@ -0,0 +1,49 @@
{
"lights": {
"blinder": {
"name": "Blinder",
"kind": "blinder",
"channels": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
},
"screen_left": {
"name": "Ecran-gauche",
"kind": "spot",
"channels": [ 16 ]
},
"screen_right": {
"name": "Ecran-droite",
"kind": "spot",
"channels": [ 17 ]
},
"wall_left": {
"name": "Mur gauche",
"kind": "spot",
"channels": [ 34 ]
},
"wall_right": {
"name": "Mur droite",
"kind": "spot",
"channels": [ 35 ]
},
"barre_led_0": {
"name": "Barre led 1",
"kind": "led_tub",
"channels" : [18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33]
},
"barre_led_1": {
"name": "Barre led 2",
"kind": "led_tub",
"channels" : [36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51]
},
"barre_led_2": {
"name": "Barre led 3",
"kind": "led_tub",
"channels" : [52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67]
},
"barre_led_3": {
"name": "Barre led 4",
"kind": "led_tub",
"channels" : [68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83]
}
}
}

View file

16
frontend/ragb/asgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
ASGI config for ragb project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ragb.settings")
application = get_asgi_application()

196
frontend/ragb/settings.py Normal file
View file

@ -0,0 +1,196 @@
"""
Django settings for ragb project.
Generated by 'django-admin startproject' using Django 4.2.5.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
import json
from pathlib import Path
from django.urls import reverse_lazy
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-elz+_!5$ad6r%%_aia_8pdzdrd(i=d_krcd@gv1t11wolz@bv^"
JWT_SECRET = "secret"
DEV_KEY = "supersecret"
# Custom settings
WEBSOCKET_PORT = 9999
WEBSOCKET_HOST = "127.0.0.1"
WEBSOCKET_ENDPOINT = f"http://{WEBSOCKET_HOST}:{WEBSOCKET_PORT}/api"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = "127.0.0.1,localhost".split(",")
INTERNAL_IPS = [
"127.0.0.1",
]
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"authens",
"frontend",
"shared",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "ragb.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "ragb.wsgi.application"
# AuthENS
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"authens.backends.ENSCASBackend",
]
AUTHENS_ALLOW_STAFF = True
AUTHENS_USE_OLDCAS = False
LOGIN_URL = reverse_lazy("authens:login")
LOGIN_REDIRECT_URL = reverse_lazy("frontend:home")
LOGOUT_REDIRECT_URL = reverse_lazy("frontend:home")
# DJDT
# DEBUG_TOOLBAR_CONFIG = {
# # Toolbar options
# "PROFILER_MAX_DEPTH": 20,
# "SHOW_TOOLBAR_CALLBACK": "ragb.utils.show_debug_toolbar",
# }
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"USER": "",
"PASSWORD": "",
"HOST": "",
"PORT": "",
},
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = (
[
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
if not DEBUG
else []
)
# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# logging
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"level": "INFO",
"class": "logging.StreamHandler",
},
},
"loggers": {
"django": {
"handlers": ["console"],
"propagate": True,
},
},
}
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = "static/"
STATIC_ROOT = "static/"
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LIGHTS = json.load(open(BASE_DIR / "patch.json"))

26
frontend/ragb/urls.py Normal file
View file

@ -0,0 +1,26 @@
"""
URL configuration for ragb project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("authens/", include("authens.urls")),
path("", include("frontend.urls")),
]

16
frontend/ragb/wsgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
WSGI config for ragb project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ragb.settings")
application = get_wsgi_application()

View file

6
frontend/shared/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class SharedConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'shared'

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

80
npins/default.nix Normal file
View file

@ -0,0 +1,80 @@
# Generated by npins. Do not modify; will be overwritten regularly
let
data = builtins.fromJSON (builtins.readFile ./sources.json);
version = data.version;
mkSource =
spec:
assert spec ? type;
let
path =
if spec.type == "Git" then
mkGitSource spec
else if spec.type == "GitRelease" then
mkGitSource spec
else if spec.type == "PyPi" then
mkPyPiSource spec
else if spec.type == "Channel" then
mkChannelSource spec
else
builtins.throw "Unknown source type ${spec.type}";
in
spec // { outPath = path; };
mkGitSource =
{
repository,
revision,
url ? null,
hash,
branch ? null,
...
}:
assert repository ? type;
# At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository
# In the latter case, there we will always be an url to the tarball
if url != null then
(builtins.fetchTarball {
inherit url;
sha256 = hash; # FIXME: check nix version & use SRI hashes
})
else
assert repository.type == "Git";
let
urlToName =
url: rev:
let
matched = builtins.match "^.*/([^/]*)(\\.git)?$" repository.url;
short = builtins.substring 0 7 rev;
appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else "";
in
"${if matched == null then "source" else builtins.head matched}${appendShort}";
name = urlToName repository.url revision;
in
builtins.fetchGit {
url = repository.url;
rev = revision;
inherit name;
# hash = hash;
};
mkPyPiSource =
{ url, hash, ... }:
builtins.fetchurl {
inherit url;
sha256 = hash;
};
mkChannelSource =
{ url, hash, ... }:
builtins.fetchTarball {
inherit url;
sha256 = hash;
};
in
if version == 3 then
builtins.mapAttrs (_: mkSource) data.pins
else
throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`"

11
npins/sources.json Normal file
View file

@ -0,0 +1,11 @@
{
"pins": {
"nixpkgs": {
"type": "Channel",
"name": "nixpkgs-unstable",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-24.11pre690413.1366d1af8f58/nixexprs.tar.xz",
"hash": "0nlrrs22pf3dr7m55glxxfdg2qn6q9sjsd5dc7bvigasb1v3gw4j"
}
},
"version": 3
}

12
provisioning/authens.nix Normal file
View file

@ -0,0 +1,12 @@
{ lib, fetchgit, pythoncas, django, ldap, buildPythonPackage }:
buildPythonPackage rec {
pname = "authens";
version = "v0.1b5";
doCheck = false;
src = fetchgit {
url = "https://git.eleves.ens.fr/klub-dev-ens/authens.git";
rev = "58747e57b30b47f36a0ed3e7c80850ed7f1edbf9";
hash = "sha256-R0Nw212/BOPHfpspT5wzxtji1vxZ/JOuwr00naklWE8=";
};
propagatedBuildInputs = [ django ldap pythoncas ];
}

View file

@ -0,0 +1,13 @@
{ lib, requests, lxml, six, buildPythonPackage, fetchFromGitHub }:
buildPythonPackage rec {
pname = "python-cas";
version = "1.6.0";
doCheck = false;
src = fetchFromGitHub {
owner = "python-cas";
repo = "python-cas";
rev = "v1.6.0";
sha512 = "sha512-qnYzgwELUij2EdqA6H17q8vnNUsfI7DkbZSI8CCIGfXOM+cZ7vsWe7CJxzsDUw73sBPB4+zzpLxvb7tpm/IDeg==";
};
propagatedBuildInputs = [ requests lxml six ];
}

20
provisioning/python.nix Normal file
View file

@ -0,0 +1,20 @@
{ lib, python3, debug ? false }:
let
python = python3.override {
packageOverrides = self: super: {
django = super.django_4;
authens = self.callPackage ./authens.nix { };
pythoncas = self.callPackage ./python-cas.nix { };
};
};
in
python.withPackages (ps: [
ps.django
ps.gunicorn
ps.authens
ps.pyjwt
] ++ lib.optionals debug [
ps.django-debug-toolbar
ps.black
ps.isort
])

85
provisioning/script.py Normal file
View file

@ -0,0 +1,85 @@
#!/nix/store/z46wvcd15152qnsry80p8ricxya2n2lr-python3-3.11.7-env/bin/python
import sys
import json
import requests
import logging
from colour import Color
from pyjecteur.fixtures import Blinder, LedBar48Ch, Tradi
from pyjecteur.lights import Universe
from pyjecteur.widget import Widget
if False: # True:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO)
w = Widget("/dev/ttyUSB0")
DIM = {
"blinder": 0.10,
"led_tub": 0.05,
"spot": 0.3,
}
u = Universe(w)
def strToProj(s):
match s:
case "spot":
return Tradi()
case "led_tub":
return LedBar48Ch()
case "blinder":
return Blinder()
r = requests.get("https://agb.hackens.org/api-docs/patch.json")
patch = r.json()
lights = {}
update = {}
current_addr = 0
for k, v in patch["lights"].items():
lights[k] = strToProj(v["kind"])
u.register(lights[k], current_addr)
# update dmx since some params are set before
lights[k].update_dmx()
logging.info(
f"Light {k} of kind {v['kind']} is at DMX{current_addr+1} (PLS convention)"
)
for i, chan in enumerate(v["channels"]):
update[chan] = (k, i) # put the light name
current_addr += lights[k].address_size
def update_light(address, red, green, blue):
light, chan = update[address]
kind = patch["lights"][light]["kind"]
r, g, b = red * DIM[kind]/255, (green * DIM[kind])/255, (blue * DIM[kind])/255
match kind:
case "blinder":
lights[light].colors[chan] = Color(rgb=(r, g, b))
case "led_tub":
lights[light].colors[chan] = Color(rgb=(r, g, b))
case "spot":
lights[light].color = Color(rgb=(r, g, b))
def run():
logging.info("Started")
for line in sys.stdin:
logging.debug(line)
if line.startswith("data:"):
dataStr = line[5:]
logging.debug(f"Received: {dataStr}")
data = json.loads(dataStr)
if data["address"] in update:
update_light(data["address"], **data["value"])
run()

11
provisioning/shell.nix Normal file
View file

@ -0,0 +1,11 @@
{ pkgs ? import (import ../npins).nixpkgs { } }:
pkgs.mkShell {
buildInputs = [
(pkgs.callPackage ./python.nix { debug = true; })
pkgs.cargo
pkgs.cargo-edit
pkgs.rustc
pkgs.rust-analyzer
pkgs.rustfmt
];
}

1
shell.nix Symbolic link
View file

@ -0,0 +1 @@
provisioning/shell.nix