aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorFuwn <[email protected]>2024-05-13 23:37:52 -0700
committerFuwn <[email protected]>2024-05-13 23:37:52 -0700
commit1d33aa6cc84d68358a537041628b179f572083ec (patch)
treefc3dec26c9280af36357100e33a30bf29998b908 /src
downloadmayu-1d33aa6cc84d68358a537041628b179f572083ec.tar.xz
mayu-1d33aa6cc84d68358a537041628b179f572083ec.zip
feat: initial release
Diffstat (limited to 'src')
-rw-r--r--src/database.gleam87
-rw-r--r--src/image.gleam55
-rw-r--r--src/mayu.gleam29
-rw-r--r--src/request.gleam57
-rw-r--r--src/svg.gleam96
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>"
+}