init
This commit is contained in:
5
server/.env.example
Normal file
5
server/.env.example
Normal 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
4
server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
/uploads
|
||||
|
||||
.env
|
||||
1901
server/Cargo.lock
generated
Normal file
1901
server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
server/Cargo.toml
Normal file
16
server/Cargo.toml
Normal 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
44
server/Dockerfile
Normal 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
16
server/compose.yml
Normal 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
102
server/src/gallery.html
Normal 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
183
server/src/main.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user