use std::{net::TcpListener, process, sync::Arc};
use http::responses::{Body, Response};
use resvg::{
tiny_skia::{Color, Pixmap, Transform},
usvg,
};
use indoc::indoc;
mod handlers;
mod http;
mod icons;
mod threads;
fn main() {
println!("luciders starting...");
let port = match std::env::var("PORT") {
Ok(port) => port,
Err(_) => "7878".to_string(),
};
let listener = TcpListener::bind(format!("[::]:{}", port))
.unwrap_or_else(|_| fatal("Failed to bind to address"));
let icons_rc = Arc::new(match icons::Icons::build() {
Ok(icons) => icons,
Err(e) => fatal(e),
});
let mut handlers = handlers::Handlers::new();
handlers.add_handler("/", |req| {
if req.method != "GET" {
return Response::new(req, 405, Body::Static("Method Not Allowed"));
}
Response::new(req, 200, Body::Static("OK - luciders is running"))
});
const INFO: &str = indoc!(
"
luciders - Lucide SVG icons server
GET /info - Get server information
GET /icons/[icon].svg - Get an icon in SVG format (equivalent to fetching them directly from Lucide)
GET /icons/[icon].png - Get an icon rendered in PNG format
Query parameters:
-- note: Make sure to URL encode the query parameters: particularly special characters such as # (e.g. %23). The server only accepts a small subset of characters deemed necessary for the query parameters.
-- note: You may choose to use either colour or color (Commonwealth or US spelling) in the query parameters. Both are accepted. Specifying both will result in undefined behaviour, so don't do that!
scale - integer - Scale the icon (default: 1, min: 1, max: 100)
padding - integer - Add padding (px) around the icon (default: 0, min: 0, max: 100)
background - hex - Set the background color in hex WITHOUT the hashtag (e.g. FFFFFF) (default: transparent)
stroke_colour - string - Set the stroke color for the SVG icon, any CSS color WITH the hashtag (default: currentColor)
discord_compatibility - boolean - Set padding to 8px for Discord compatibility. Typically for use in embed author icons as these have a circle clip applied by Discord. Overrides padding if set. (default: false)
cache_key - string - Technically this can have any name. The server does not interpret this at all. Useful for cache busting. (default: none)
Example:
/icons/apple.png?scale=2&background=FF0000&discord_compatibility
*** mantained with ❤️ by Compositr (Jim) ***
"
);
handlers.add_handler("/info", |req| {
if req.method != "GET" {
return Response::new(req, 405, Body::Static("Method Not Allowed"));
}
Response::new(req, 200, Body::Static(INFO))
});
handlers.add_handler("/icons/[icon].svg", {
let icons = Arc::clone(&icons_rc);
move |req| {
if req.method != "GET" {
return Response::new(req, 405, Body::Static("Method Not Allowed"));
}
let icon_filename = match req.url.get_path_segment(1) {
Some(icon) => icon,
None => return Response::new(req, 404, Body::Static("Icon Segment Not Found")),
};
let icon_name = &icon_filename[..icon_filename.len() - icons::ICON_FILE_EXTENSION_LEN];
if !icons.has_iconname(icon_name) {
return Response::new(req, 404, Body::Static("Icon Not Found"));
}
let icon = match icons.get_icon(icon_name) {
Some(icon) => icon,
None => {
error("Failed to read icon");
return Response::new(req, 500, Body::Static("Failed to read icon"));
}
};
return Response::new(req, 200, Body::Bytes(icon, "image/svg+xml"));
}
});
handlers.add_handler("/icons/[icon].png", {
let icons = Arc::clone(&icons_rc);
move |req| {
if req.method != "GET" {
return Response::new(req, 405, Body::Static("Method Not Allowed"));
}
let icon_filename = match req.url.get_path_segment(1) {
Some(icon) => icon,
None => return Response::new(req, 404, Body::Static("Icon Segment Not Found")),
};
let icon_name = &icon_filename[..icon_filename.len() - icons::ICON_FILE_EXTENSION_LEN];
if !icons.has_iconname(icon_name) {
return Response::new(req, 404, Body::Static("Icon Not Found"));
}
let mut icon_string = match icons.get_icon_string(icon_name) {
Some(icon) => icon,
None => {
error("Failed to read icon");
return Response::new(req, 500, Body::Static("Failed to read icon"))
},
};
// Options handling
let mut scale = 1;
match req.url.query.get("scale") {
Some(scale_str) => {
match scale_str.parse::() {
Ok(scale_val) => {
if scale_val <= 0 || scale_val > 100 {
return Response::new(req, 400, Body::Static("Invalid scale value. Scale value must be an integer > 0 and <= 100"));
}
scale = scale_val;
}
Err(_) => return Response::new(req, 400, Body::Static("Invalid scale value. Scale value must be an integer > 0 and <= 100"))
}
}
None => {}
}
let mut padding = 0;
match req.url.query.get("padding") {
Some(padding_str) => {
match padding_str.parse::() {
Ok(padding_val) => {
if padding_val > 100 {
return Response::new(req, 400, Body::Static("Invalid padding value. Padding value must be an integer >= 0 and <= 100"));
}
padding = padding_val;
}
Err(_) => return Response::new(req, 400, Body::Static("Invalid padding value. Padding value must be an integer >= 0 and <= 100"))
}
}
None => {}
}
match req.url.query.get("discord_compatibility") {
Some(_) => {
padding = 8;
}
None => {}
}
let mut background = Color::TRANSPARENT;
match req.url.query.get("background") {
Some(background_str) => {
match u32::from_str_radix(&background_str, 16) {
Ok(background_val) => {
if background_val > 0xFFFFFF {
return Response::new(req, 400, Body::Static("Invalid background value. Background value must be a valid hex color #000000 - #FFFFFF"));
}
let red = ((background_val >> 16) & 0xFF) as u8;
let green = ((background_val >> 8) & 0xFF) as u8;
let blue = (background_val & 0xFF) as u8;
background = Color::from_rgba8(red, green, blue, 255);
}
Err(_) => return Response::new(req, 400, Body::Static("Invalid background value. Background value must be a valid hex color"))
}
}
None => {}
}
let mut svg_stroke = "currentColor";
// thx horizell for this code
match (req.url.query.get("stroke_color"), req.url.query.get("stroke_colour")) {
(Some(stroke_str), _) | (_, Some(stroke_str)) => svg_stroke = stroke_str,
_ => {}
}
icon_string = icon_string.replace("currentColor", &svg_stroke);
// Rendering
let tree = match usvg::Tree::from_str(&icon_string, &usvg::Options::default()) {
Ok(tree) => tree,
Err(e) => {
error(&format!("Failed to load icon into tree: {}", e));
return Response::new(req, 500, Body::Static("Failed to load icon into tree"))
}
};
let length = 24 * scale + padding;
let mut pixmap = match Pixmap::new(length, length) {
Some(pixmap) => pixmap,
None => {
error("Failed to create pixmap");
return Response::new(req, 500, Body::Static("Failed to create pixmap"))
},
};
let mut pixmap_mut = pixmap.as_mut();
pixmap_mut.fill(background);
resvg::render(
&tree,
Transform::from_scale(scale as f32, scale as f32)
.post_concat(Transform::from_translate(length as f32 / 2.0 - 12.0 * scale as f32, length as f32 / 2.0 - 12.0 * scale as f32)),
&mut pixmap_mut,
);
let png = match pixmap.encode_png() {
Ok(png) => png,
Err(e) => {
error(&format!("Failed to encode PNG: {}", e));
return Response::new(req, 500, Body::Static("Failed to encode PNG"))
},
};
return Response::new(req, 200, Body::Bytes(png, "image/png"));
}
});
handlers
.bind(listener)
.unwrap_or_else(|_| fatal("Failed to bind handlers"));
println!("luciders started on port {}", port);
println!("Serving {} icons", icons_rc.icons_len())
}
fn fatal(msg: &'static str) -> ! {
eprintln!("[FATAL] {}", msg);
process::exit(1);
}
fn error(msg: &str) {
eprintln!("[ERROR] {}", msg);
}