This commit is contained in:
2026-01-12 00:53:54 +01:00
commit 2f827c168d
26 changed files with 3145 additions and 0 deletions

5
server/.env.example Normal file
View File

@@ -0,0 +1,5 @@
SERVER_HOST=0.0.0.0
SERVER_PORT=3000
GALLERY_PASSWORD=partytime
UPLOAD_DIR=./uploads
RUST_LOG=info,partycam_server=debug

4
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target
/uploads
.env

1901
server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
server/Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "server"
version = "1.0.0"
edition = "2024"
[dependencies]
anyhow = "1.0.100"
axum = "0.8.8"
base64 = "0.22.1"
chrono = "0.4.42"
dotenvy = "0.15.7"
image = { version = "0.25.9", features = ["png"] }
tokio = { version = "1.49.0", features = ["full"] }
tower-http = { version = "0.6.8", features = ["fs", "trace", "cors"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "fmt"] }

44
server/Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
# 1. Builder Stage
FROM rust:1.92 AS builder
WORKDIR /app
# --- OPTIMIZATION: Dependency Caching ---
# 1. Create a dummy project to cache dependencies
RUN mkdir src
RUN echo "fn main() {}" > src/main.rs
# 2. Copy manifests
COPY Cargo.toml Cargo.lock ./
# 3. Build only dependencies (this layer is cached until Cargo.toml changes)
RUN cargo build --release
# 4. Now remove dummy source and copy your actual source
# We 'touch' main.rs to ensure Cargo realizes it needs a rebuild
RUN rm -rf src
COPY . .
RUN touch src/main.rs
# 5. Build the actual application
RUN cargo build --release
# 2. Runtime Stage
FROM debian:bookworm-slim
WORKDIR /app
# Install OpenSSL & CA Certificates
RUN apt-get update && \
apt-get install -y libssl3 ca-certificates && \
rm -rf /var/lib/apt/lists/*
# Copy the binary from builder
# Note: Ensure the binary name matches 'package.name' in Cargo.toml ("server")
COPY --from=builder /app/target/release/server .
# Documentation for ports
EXPOSE 3000
# Run the binary
CMD ["./server"]

16
server/compose.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
server:
build: .
ports:
- "3000:3000"
volumes:
# MAPPING: [Host Folder] : [Container Folder]
# You can change './party_images' to whatever folder on your laptop you want.
- ./party_images:/app/uploads
environment:
# We FORCE the internal path to match the volume mount above.
# This overrides whatever is in your .env file for safety.
- UPLOAD_DIR=/app/uploads
- SERVER_HOST=0.0.0.0
- GALLERY_PASSWORD=partytime
restart: unless-stopped

102
server/src/gallery.html Normal file
View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Party Cam Gallery</title>
<style>
body {
font-family: sans-serif;
background: #1a1a1a;
color: #fff;
margin: 0;
padding: 20px;
}
h1 {
text-align: center;
margin-bottom: 30px;
}
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
max-width: 1000px;
margin: 0 auto;
}
.card {
background: #2a2a2a;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
transition: transform 0.2s;
}
.card:hover {
transform: scale(1.02);
}
img {
width: 100%;
height: auto;
display: block;
image-rendering: pixelated; /* Keeps that retro look crisp */
}
.meta {
padding: 10px;
font-size: 0.8em;
color: #888;
text-align: center;
}
</style>
</head>
<body>
<h1>📸 Live Party Gallery</h1>
<div id="gallery" class="gallery"></div>
<script>
const gallery = document.getElementById("gallery");
let currentImages = new Set();
async function refresh() {
try {
const res = await fetch("/gallery/data");
if (res.status === 401) window.location.reload(); // Trigger auth prompt
const images = await res.json();
// Simple diffing to avoid reloading existing images
images.forEach((img) => {
if (!currentImages.has(img)) {
addCard(img);
currentImages.add(img);
}
});
} catch (e) {
console.error(e);
}
}
function addCard(filename) {
const div = document.createElement("div");
div.className = "card";
div.innerHTML = `
<a href="/gallery/img/${filename}" target="_blank">
<img src="/gallery/img/${filename}" loading="lazy" alt="Party Photo">
</a>
<div class="meta">${filename
.replace("capture_", "")
.replace(".png", "")
.replace("_", " ")}</div>
`;
// Prepend to show new ones at top
gallery.prepend(div);
}
// Initial load and poll every 5 seconds
refresh();
setInterval(refresh, 5000);
</script>
</body>
</html>

183
server/src/main.rs Normal file
View File

@@ -0,0 +1,183 @@
use std::{env, net::SocketAddr, path::PathBuf, sync::Arc};
use axum::{
Router,
body::Bytes,
extract::{Path, State},
http::{StatusCode, header},
middleware::{self, Next},
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use base64::Engine;
use image::{GrayImage, Luma};
use tower_http::trace::TraceLayer;
#[derive(Clone)]
struct AppState {
upload_dir: PathBuf,
password: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let host = env::var("SERVER_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
let port = env::var("SERVER_PORT").unwrap_or_else(|_| "3000".to_string());
let password = env::var("GALLERY_PASSWORD").expect("GALLERY_PASSWORD must be set");
let upload_dir =
PathBuf::from(env::var("UPLOAD_DIR").unwrap_or_else(|_| "./uploads".to_string()));
tokio::fs::create_dir_all(&upload_dir).await?;
let state = Arc::new(AppState {
upload_dir,
password,
});
let app = Router::new()
.route("/upload", post(upload_handler))
.nest("/gallery", protected_routes(state.clone()))
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
tracing::info!("🚀 Server running on http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn protected_routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
Router::new()
.route("/", get(gallery_view))
.route("/data", get(gallery_data))
.route("/img/{filename}", get(serve_image))
.route_layer(middleware::from_fn_with_state(state, basic_auth))
}
async fn upload_handler(
State(state): State<Arc<AppState>>,
body: Bytes,
) -> Result<StatusCode, StatusCode> {
let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S");
let filename = format!("capture_{}.png", timestamp);
let path = state.upload_dir.join(&filename);
let path_clone = path.clone();
tracing::info!("Receiving upload: {} bytes", body.len());
tokio::task::spawn_blocking(move || convert_and_save(&body, &path))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map_err(|e| {
tracing::error!("Failed to process image: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
tracing::info!("Saved: {:?}", path_clone);
Ok(StatusCode::OK)
}
async fn gallery_view() -> Html<&'static str> {
Html(include_str!("gallery.html"))
}
async fn gallery_data(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let mut entries = tokio::fs::read_dir(&state.upload_dir).await.unwrap();
let mut images = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if let Some(ext) = path.extension() {
if ext == "png" {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
images.push(name.to_string());
}
}
}
}
images.sort_by(|a, b| b.cmp(a));
axum::Json(images)
}
async fn serve_image(
State(state): State<Arc<AppState>>,
Path(filename): Path<String>,
) -> impl IntoResponse {
let path = state.upload_dir.join(&filename);
match tokio::fs::read(&path).await {
Ok(bytes) => ([(header::CONTENT_TYPE, "image/png")], bytes).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
async fn basic_auth(
State(state): State<Arc<AppState>>,
req: axum::extract::Request,
next: Next,
) -> Response {
// Check for "Authorization" header
if let Some(auth_header) = req.headers().get("Authorization") {
if let Ok(auth_str) = auth_header.to_str() {
if auth_str.starts_with("Basic ") {
let payload = &auth_str[6..]; // Remove "Basic "
if let Ok(decoded) = base64::prelude::BASE64_STANDARD.decode(payload) {
if let Ok(cred) = String::from_utf8(decoded) {
if let Some((_user, pass)) = cred.split_once(':') {
if pass == state.password {
return next.run(req).await;
}
}
}
}
}
}
}
// Return 401 Challenge if auth fails
(
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Basic realm=\"PartyCam\"")],
"Unauthorized",
)
.into_response()
}
fn convert_and_save(data: &[u8], path: &PathBuf) -> anyhow::Result<()> {
// Dimensions from ESP32 code (240x320)
let width = 240;
let height = 320;
let mut img = GrayImage::new(width, height);
// Unpack bits
// The ESP32 code: bitmap[y * rowBytes + (x / 8)] |= (1 << (7 - (x % 8)));
// We reverse this logic.
let row_bytes = (width + 7) / 8;
for y in 0..height {
for x in 0..width {
let byte_index = (y * row_bytes + (x / 8)) as usize;
if byte_index < data.len() {
let byte = data[byte_index];
let bit = (byte >> (7 - (x % 8))) & 1;
// 1 = Black (Heat), 0 = White
// In PNG Gray8: 0 = Black, 255 = White.
// So if bit is 1, we want 0 (Black).
let pixel_val = if bit == 1 { 0 } else { 255 };
img.put_pixel(x, y, Luma([pixel_val]));
}
}
}
img.save(path)?;
Ok(())
}