diff options
| author | Fuwn <[email protected]> | 2024-05-13 23:37:52 -0700 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2024-05-13 23:37:52 -0700 |
| commit | 1d33aa6cc84d68358a537041628b179f572083ec (patch) | |
| tree | fc3dec26c9280af36357100e33a30bf29998b908 /src | |
| download | mayu-1d33aa6cc84d68358a537041628b179f572083ec.tar.xz mayu-1d33aa6cc84d68358a537041628b179f572083ec.zip | |
feat: initial release
Diffstat (limited to 'src')
| -rw-r--r-- | src/database.gleam | 87 | ||||
| -rw-r--r-- | src/image.gleam | 55 | ||||
| -rw-r--r-- | src/mayu.gleam | 29 | ||||
| -rw-r--r-- | src/request.gleam | 57 | ||||
| -rw-r--r-- | src/svg.gleam | 96 |
5 files changed, 324 insertions, 0 deletions
diff --git a/src/database.gleam b/src/database.gleam new file mode 100644 index 0000000..bba3b29 --- /dev/null +++ b/src/database.gleam @@ -0,0 +1,87 @@ +import birl +import gleam/dynamic +import sqlight + +pub type Counter { + Counter(name: String, num: Int, created_at: String, updated_at: String) +} + +pub fn setup(connection) { + let _ = + sqlight.exec( + "pragma foreign_keys = on; + + create table if not exists tb_count ( + id integer primary key autoincrement not null unique, + name text not null unique, + num int not null default (0) + ) strict;", + connection, + ) + let add_column = fn(name) { + let _ = + sqlight.exec( + "alter table tb_count add column " + <> name + <> " text default current_timestamp;", + connection, + ) + + Nil + } + + add_column("created_at") + add_column("updated_at") + + Nil +} + +pub fn add_counter(connection, name) { + sqlight.query( + "insert into tb_count (name) values (?);", + with: [sqlight.text(name)], + on: connection, + expecting: dynamic.optional(dynamic.int), + ) +} + +pub fn get_counter(connection, name) { + let _ = + sqlight.query( + "insert or ignore into tb_count (name) values (?);", + with: [sqlight.text(name)], + on: connection, + expecting: dynamic.optional(dynamic.int), + ) + let _ = + sqlight.query( + "update tb_count set num = num + 1, updated_at = ? where name = ?;", + with: [sqlight.text(birl.to_iso8601(birl.utc_now())), sqlight.text(name)], + on: connection, + expecting: dynamic.int, + ) + + case + sqlight.query( + "select name, num, created_at, updated_at from tb_count where name = ?;", + with: [sqlight.text(name)], + on: connection, + expecting: dynamic.tuple4( + dynamic.string, + dynamic.int, + dynamic.string, + dynamic.string, + ), + ) + { + Ok([first_element]) -> { + Counter( + first_element.0, + first_element.1, + first_element.2, + first_element.3, + ) + } + _ -> Counter(name, 0, "", "") + } +} diff --git a/src/image.gleam b/src/image.gleam new file mode 100644 index 0000000..6672b46 --- /dev/null +++ b/src/image.gleam @@ -0,0 +1,55 @@ +import gleam/int + +pub type ImageDimensions { + ImageDimensions(width: Int, height: Int) +} + +pub fn get_image_dimensions(image) { + case image { + <<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, _:bits>> -> + parse_png_chunks(image, 8) + << + 0x47, + 0x49, + 0x46, + _:16, + _:unsigned, + width_0:8, + width_1:8, + height_0:8, + height_1:8, + _rest:bits, + >> -> + Ok(ImageDimensions( + width_0 + |> int.bitwise_or( + width_1 + |> int.bitwise_shift_left(8), + ), + height_0 + |> int.bitwise_or( + height_1 + |> int.bitwise_shift_left(8), + ), + )) + _ -> Error("Invalid PNG signature") + } +} + +fn parse_png_chunks(image, offset) { + let offset_bits = offset * 8 + + case image { + << + _:size(offset_bits), + _length:32, + "IHDR":utf8, + width:32, + height:32, + _:bits, + >> -> Ok(ImageDimensions(width, height)) + <<_:size(offset), length:32, _:4, _:bits>> -> + parse_png_chunks(image, offset + length + 12) + _ -> Error("Invalid PNG chunk") + } +} diff --git a/src/mayu.gleam b/src/mayu.gleam new file mode 100644 index 0000000..69e1474 --- /dev/null +++ b/src/mayu.gleam @@ -0,0 +1,29 @@ +import database +import gleam/erlang/process +import mist +import request +import simplifile +import sqlight +import wisp + +pub fn main() { + wisp.configure_logger() + + let _ = simplifile.create_directory("./data") + + use connection <- sqlight.with_connection("./data/mayu.sqlite3") + + database.setup(connection) + + let secret_key_base = wisp.random_string(64) + let assert Ok(_) = + wisp.mist_handler( + fn(request) { request.handle(request, connection) }, + secret_key_base, + ) + |> mist.new + |> mist.port(8080) + |> mist.start_http + + process.sleep_forever() +} diff --git a/src/request.gleam b/src/request.gleam new file mode 100644 index 0000000..8476fd5 --- /dev/null +++ b/src/request.gleam @@ -0,0 +1,57 @@ +import database +import gleam/json +import gleam/string_builder +import svg +import wisp.{type Response} + +fn middleware( + request: wisp.Request, + handle: fn(wisp.Request) -> wisp.Response, +) -> wisp.Response { + let request = wisp.method_override(request) + + use <- wisp.log_request(request) + use <- wisp.rescue_crashes + use request <- wisp.handle_head(request) + + handle(request) +} + +pub fn handle(request, connection) -> Response { + use _ <- middleware(request) + + case wisp.path_segments(request) { + ["heart-beat"] -> + wisp.html_response(string_builder.from_string("alive"), 200) + ["get", "@" <> name] -> + wisp.ok() + |> wisp.set_header("Content-Type", "image/svg+xml") + |> wisp.string_body(svg.xml( + case wisp.get_query(request) { + [#("theme", theme)] -> theme + _ -> "asoul" + }, + database.get_counter(connection, name).num, + )) + ["record", "@" <> name] -> { + let counter = database.get_counter(connection, name) + + case + Ok( + json.to_string_builder( + json.object([ + #("name", json.string(counter.name)), + #("num", json.int(counter.num)), + #("updated_at", json.string(counter.updated_at)), + #("created_at", json.string(counter.created_at)), + ]), + ), + ) + { + Ok(builder) -> wisp.json_response(builder, 200) + Error(_) -> wisp.unprocessable_entity() + } + } + _ -> wisp.html_response(string_builder.from_string("Not found"), 404) + } +} diff --git a/src/svg.gleam b/src/svg.gleam new file mode 100644 index 0000000..317c2d8 --- /dev/null +++ b/src/svg.gleam @@ -0,0 +1,96 @@ +import gleam/bit_array +import gleam/int +import gleam/list +import gleam/string_builder +import image +import simplifile + +type XmlImages { + XmlImages(xml: String, width: Int) +} + +fn image(data, dimensions: image.ImageDimensions, width, extension) { + "<image + height=\"" <> int.to_string(dimensions.height) <> "\" + width=\"" <> int.to_string(dimensions.width) <> "\" + x=\"" <> int.to_string(width) <> "\" + y=\"0\" + xlink:href=\"data:image/" <> extension <> ";base64," <> bit_array.base64_encode( + data, + False, + ) <> "\"/>" +} + +fn images(theme, digits, width, height, svgs) { + case digits { + [] -> XmlImages(string_builder.to_string(svgs), width) + [digit, ..rest] -> { + let extension = case theme { + "asoul" | "gelbooru" | "moebooru" | "rule34" | "urushi" -> "gif" + _ -> "png" + } + + case + simplifile.read_bits( + from: "./themes/" + <> theme + <> "/" + <> int.to_string(digit) + <> "." + <> extension, + ) + { + Ok(data) -> { + case image.get_image_dimensions(data) { + Ok(dimensions) -> + images( + theme, + rest, + width + dimensions.width, + int.max(height, dimensions.height), + string_builder.append( + svgs, + image(data, dimensions, width, extension), + ), + ) + Error(_) -> XmlImages(string_builder.to_string(svgs), width) + } + } + Error(_) -> XmlImages(string_builder.to_string(svgs), width) + } + } + } +} + +pub fn xml(theme, number) { + let xml = + images( + theme, + { + let assert Ok(digits) = int.digits(number, 10) + let digits_padding = 6 - list.length(digits) + + case digits_padding { + n if n > 0 -> list.concat([list.repeat(0, digits_padding), digits]) + _ -> digits + } + }, + 0, + 0, + string_builder.new(), + ) + + "<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?> + <svg + height=\"{height}\" + style=\"image-rendering: pixelated;\" + version=\"1.1\" + width=\"" <> int.to_string(xml.width) <> "\" + xmlns=\"http://www.w3.org/2000/svg\" + xmlns:xlink=\"http://www.w3.org/1999/xlink\" + > + <title>Mayu</title> + + <g>" <> xml.xml <> "</g> + </svg>" +} |