···77FROM ghcr.io/gleam-lang/gleam:v1.15.2-erlang-alpine AS build-client
88WORKDIR /app
99COPY ./client/gleam.toml ./client/manifest.toml ./
1010+COPY ./webapi/ ../webapi/
1011RUN gleam deps download
1112COPY ./client/ ./
1213COPY --from=style-client /app/prepped/ /app/prepped/
···2627RUN apk add build-base
2728WORKDIR /app
2829COPY ./server/gleam.toml ./server/manifest.toml ./
3030+COPY ./webapi/ ../webapi/
2931RUN gleam deps download
3032COPY ./server/ /app/
3131-RUN cd /app/ && gleam export erlang-shipment
3333+RUN --mount=type=cache,target=/app/build \
3434+ gleam export erlang-shipment && \
3535+ # Move the result out of the cache volume so the next stage can see it
3636+ cp -r /app/build/erlang-shipment /app/shipment_final
3737+# RUN cd /app/ && gleam export erlang-shipment # <-- THIS IS THE SLOW STEP.
32383339FROM docker.io/library/erlang:28-alpine
3440RUN mkdir -p /data && chown 1000 /data
···3642COPY --from=ghcr.io/amacneil/dbmate:latest /usr/local/bin/dbmate /usr/local/bin/dbmate
3743COPY ./db/migrations /app/migrations
3844COPY --from=package-client --chown=1000 /build/dist /app/lumina_server/priv/static
3939-COPY --from=package-server --chown=1000 /app/build/erlang-shipment /app
4545+COPY --from=package-server --chown=1000 /app/shipment_final /app
40464147WORKDIR /app
4248
+5-4
backend/impl-gleam/Justfile
···66copy-over-client:
77 mkdir -p ./client
88 cp -fru $(git rev-parse --show-toplevel)/web/* ./client
99-99+ mkdir -p ./webapi
1010+ cp -fru $(git rev-parse --show-toplevel)/webapi/* ./webapi
1011[doc("Build the styles for Lumina client")]
1112[group('building')]
1213build-styles:
1313-1414+14151516[doc("Build the server-side of Lumina into a Podman image, from the Flake! This builds most of Lumina inside your worktree, albeit not tracked.")]
1617[group('building')]
···5859[group('prepare')]
5960create-data-dirs:
6061 mkdir -p ./data/configvars/
6161- chmod 777 data
6262+ chmod 777 data -fR || true
62636364[doc("Clean all build artifacts")]
6465clean-all:
···8687[doc("Run the server in development mode with file watching")]
8788[group("local-devel")]
8889local-devel-watch:
8989- watchexec --restart --debounce 10 --stop-timeout=0 --shell=sh -e rs,gleam,toml,css,ts,json --print-events -- just local-devel
9090+ watchexec --restart -I -c --debounce=10s --stop-timeout=0 --shell=sh -e rs,gleam,toml,css,ts,json --print-events -- just local-devel
90919192[doc("Runs the commands from local-devel automatically, watches")]
9293[group("local-devel")]
+3-1
backend/impl-gleam/server/gleam.toml
···66# your project to the Hex package manager.
77#
88# description = ""
99-# licences = ["Apache-2.0"]
99+licences = ["EUPL-1.2"]
1010# repository = { type = "github", user = "", repo = "" }
1111# links = [{ title = "Website", href = "" }]
1212#
···3434simplifile = ">= 2.4.0 and < 3.0.0"
3535booklet = ">= 1.1.0 and < 2.0.0"
3636humanise = ">= 1.1.0 and < 2.0.0"
3737+webapi = { path = "../webapi" }
3838+37393840[dev_dependencies]
3941gleeunit = ">= 1.0.0 and < 2.0.0"
+2
backend/impl-gleam/server/manifest.toml
···4343 { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" },
4444 { name = "sqlight", version = "1.0.3", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "CADD79663C9B61D4BAC960A47CC2D42CA8F48EAF5804DBEB79977287750F4B16" },
4545 { name = "tom", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "234A842F3D087D35737483F5DFB6DE9839E3366EF0CAF8726D2D094210227670" },
4646+ { name = "webapi", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../webapi" },
4647 { name = "websocks", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_stdlib"], otp_app = "websocks", source = "hex", outer_checksum = "C70340E5B6C3390383ADA17029DCA6F8903863A7AD8CD8E1520EDCC4FE70D6FD" },
4748 { name = "wisp", version = "2.2.2", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "5FF5F1E288C3437252ABB93D8F9CF42FF652CE7AD54480CFE736038DC09C4F22" },
4849 { name = "woof", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "woof", source = "hex", outer_checksum = "A5DC2FCB04F23B0F440978885A167A91450B88F7760B969127187C57E05D489C" },
···6869shellout = { version = ">= 1.8.0 and < 2.0.0" }
6970simplifile = { version = ">= 2.4.0 and < 3.0.0" }
7071sqlight = { version = ">= 1.0.3 and < 2.0.0" }
7272+webapi = { path = "../webapi" }
7173wisp = { version = ">= 2.2.2 and < 3.0.0" }
7274woof = { version = ">= 1.2.0 and < 2.0.0" }
7375youid = { version = ">= 1.6.0 and < 2.0.0" }
+43-9
backend/impl-gleam/server/src/lumina_server.gleam
···1616// This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5]
1717// See the Licence for the specific language governing permissions and limitations. [cite: 6]
18181919-import booklet.{type Booklet}
1919+import webapi
2020+import gleam/json
2121+import booklet
2022import envoy
2123import ewe.{type Request, type Response}
2224import gleam/bit_array
···120122 True ->
121123 case events.log_to_db(entry, fields_formatted, db) {
122124 Ok(_) -> Nil
123123- _ -> {
124124- // echo e
125125- log_to_db |> booklet.set(False)
126126- woof.error("Could not log to database! No longer trying.", [])
125125+ Error(e) -> {
126126+ woof.error("Could not log to database!\n\n"<>e.message, [])
127127+ woof.append_global_context([woof.field("db_logging", "failed: " <> e.message)])
128128+ booklet.set(in: log_to_db, to: False)
127129 }
128130 }
129131 False -> Nil
···317319}
318320319321fn client_communication_handler(
320320- _conn: ewe.WebsocketConnection,
322322+conn: ewe.WebsocketConnection,
321323 state: WebsocketState,
322324 // That Nil is the internal message, again if we'd follow the example. But
323325 // Lumina mostly communicates with the database and stores more global variables in Booklets (which is ETS)... So no need.
···335337 case message {
336338 ewe.Text(json_str) -> {
337339 connection_logger(woof.Debug, "Received: " <> json_str, [])
338338- // Todo
339339- ewe.websocket_continue(state)
340340- }
340340+ case json.parse(json_str, webapi.ws_msg_from_client_decoder()) {
341341+342342+ Error(_) -> {
343343+ woof.tap_debug(woof.Warning, "Received malformed message from client.", [
344344+ woof.field("message", json_str),
345345+ ])
346346+ ewe.send_close_frame(conn, ewe.CustomCloseCode(code: 4000, data: "Malformed message received."))
347347+ Some(ewe.websocket_stop_abnormal("Malformed message received."))
348348+ }
349349+ Ok(message) ->
350350+ case message {
351351+ webapi.Introduction(client_kind:, try_revive:) -> {
352352+ case try_revive {
353353+ Some(_) -> todo as "Revive is not implemented yet."
354354+ None -> Nil
355355+ }
356356+ let client_type = case client_kind {
357357+ "web" -> {
358358+ connection_logger(woof.Debug, "A web client greeds us!", [])
359359+ WebClient}
360360+ _ -> todo
361361+ }
362362+ Some(ewe.websocket_continue(WebsocketState(..state, conn_data: ClientConnectionData(..connection_data, client_type: Some(client_type)))))
363363+ }
364364+ webapi.PostContentRequest(post_id:) -> todo
365365+ webapi.RegisterPrecheck(email:, username:, password:) -> todo
366366+ webapi.TimeLineRequest(timeline_name:, page:) -> todo
367367+ webapi.RegisterRequest(email:, username:, password:) -> todo
368368+ webapi.LoginAuthenticationRequest(email_username:, password:) -> todo
369369+ webapi.OwnUserInformationRequest -> todo
370370+ }
371371+ }
372372+ |> option.unwrap(ewe.websocket_continue(state))
373373+374374+ }
341375 ewe.Binary(_) -> ewe.websocket_continue(state)
342376 ewe.User(Nil) -> ewe.websocket_continue(state)
343377 }
···11+# webapi
22+33+[](https://hex.pm/packages/webapi)
44+[](https://hexdocs.pm/webapi/)
55+66+```sh
77+gleam add webapi@1
88+```
99+1010+```gleam
1111+import webapi
1212+1313+pub fn main() -> Nil {
1414+ // TODO: An example of the project in use
1515+}
1616+```
1717+1818+Further documentation can be found at <https://hexdocs.pm/webapi>.
1919+2020+## Development
2121+2222+```sh
2323+gleam run # Run the project
2424+gleam test # Run the tests
2525+```
+20
webapi/gleam.toml
···11+name = "webapi"
22+version = "1.0.0"
33+44+# Fill out these fields if you intend to generate HTML documentation or publish
55+# your project to the Hex package manager.
66+#
77+# description = ""
88+# licences = ["Apache-2.0"]
99+# repository = { type = "github", user = "", repo = "" }
1010+# links = [{ title = "Website", href = "" }]
1111+#
1212+# For a full reference of all the available options, you can have a look at
1313+# https://gleam.run/writing-gleam/gleam-toml/.
1414+1515+[dependencies]
1616+gleam_stdlib = ">= 0.44.0 and < 2.0.0"
1717+gleam_json = ">= 3.1.0 and < 4.0.0"
1818+1919+[dev_dependencies]
2020+gleeunit = ">= 1.0.0 and < 2.0.0"
+13
webapi/manifest.toml
···11+# This file was generated by Gleam
22+# You typically do not need to edit this file
33+44+packages = [
55+ { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
66+ { name = "gleam_stdlib", version = "0.71.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "702F3BC2A14793906880B1078B19A6165F87323AEE8D0C4A34085846336FCAAE" },
77+ { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
88+]
99+1010+[requirements]
1111+gleam_json = { version = ">= 3.1.0 and < 4.0.0" }
1212+gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
1313+gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+282
webapi/src/webapi.gleam
···11+//// Lumina > Web API types and decoders/encoders for server-client communication.
22+33+// Lumina/Peonies
44+// Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4]
55+//
66+// This software is licensed under the European Union Public Licence (EUPL) v1.2.
77+// You may not use this work except in compliance with the Licence.
88+// You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
99+//
1010+// AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED
1111+// under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work.
1212+// See LICENSE file in the repository root for full details.
1313+//
1414+//
1515+// This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5]
1616+// See the Licence for the specific language governing permissions and limitations. [cite: 6]
1717+1818+1919+import gleam/dynamic/decode
2020+import gleam/json
2121+import gleam/option.{Some, None}
2222+2323+/// Message types for websocket communication from server to client.
2424+pub type WsMsgFromServer {
2525+ Greeting(greeting: String)
2626+ RegisterPrecheckResponse(ok: Bool, why: String)
2727+ AuthenticationSuccess(username: String, token: String)
2828+ AuthenticationFailure
2929+ TimeLineResponse(
3030+ timeline_name: String,
3131+ timeline_id: String,
3232+ /// List of post ids as string.
3333+ items: List(String),
3434+ /// Total number of posts in timeline
3535+ total_count: Int,
3636+ /// Current page number
3737+ page: Int,
3838+ /// Whether there are more pages available
3939+ has_more: Bool,
4040+ )
4141+ OwnUserInformationResponse(
4242+ username: String,
4343+ email: String,
4444+ // Optional field populated with mime type and base64 of a profile picture.
4545+ avatar: option.Option(#(String, String)),
4646+ uuid: String,
4747+ /// Number of unread notifications, a timeline request for "notifications" can be used to get the actual notifications and fill the cache.
4848+ unread_notifications: Int,
4949+ )
5050+ Undecodable
5151+}
5252+5353+pub fn ws_msg_from_server_decoder() -> decode.Decoder(WsMsgFromServer) {
5454+ use variant <- decode.field("type", decode.string)
5555+5656+ case variant {
5757+ "auth_success" -> {
5858+ use username <- decode.field("username", decode.string)
5959+ use token <- decode.field("token", decode.string)
6060+ decode.success(AuthenticationSuccess(username:, token:))
6161+ }
6262+ "auth_failure" -> {
6363+ decode.success(AuthenticationFailure)
6464+ }
6565+ "unknown" -> decode.success(Undecodable)
6666+ "register_precheck_response" -> {
6767+ use ok <- decode.field("ok", decode.bool)
6868+ use why <- decode.field("why", decode.string)
6969+ decode.success(RegisterPrecheckResponse(ok, why))
7070+ }
7171+ "greeting" -> {
7272+ use greeting <- decode.field("greeting", decode.string)
7373+ decode.success(Greeting(greeting:))
7474+ }
7575+ "timeline_response" -> {
7676+ echo "Decoding timeline response: " <> variant
7777+ use timeline_name <- decode.field("timeline_name", decode.string)
7878+ use timeline_id <- decode.field("timeline_id", decode.string)
7979+ use items <- decode.field("post_ids", decode.list(decode.string))
8080+ use total_count <- decode.field("total_count", decode.int)
8181+ use page <- decode.field("page", decode.int)
8282+ use has_more <- decode.field("has_more", decode.bool)
8383+ decode.success(TimeLineResponse(
8484+ timeline_name:,
8585+ timeline_id:,
8686+ items:,
8787+ total_count:,
8888+ page:,
8989+ has_more:,
9090+ ))
9191+ }
9292+ "own_user_information_response" -> {
9393+ use username <- decode.field("username", decode.string)
9494+ use email <- decode.field("email", decode.string)
9595+ use unread_notifications <- decode.field(
9696+ "unread_notifications",
9797+ decode.int,
9898+ )
9999+ // avatar may be null or an array [mime, base64]
100100+ use avatar_list_opt <- decode.field(
101101+ "avatar",
102102+ decode.optional(decode.list(decode.string)),
103103+ )
104104+ let avatar = case avatar_list_opt {
105105+ Some(list) ->
106106+ case list {
107107+ [mime, b64] -> Some(#(mime, b64))
108108+ _ -> None
109109+ }
110110+ None -> None
111111+ }
112112+ use uuid <- decode.field("uuid", decode.string)
113113+ decode.success(OwnUserInformationResponse(
114114+ username:,
115115+ email:,
116116+ avatar:,
117117+ uuid:,
118118+ unread_notifications:,
119119+ ))
120120+ }
121121+ g -> {
122122+ decode.failure(Undecodable, g)
123123+ }
124124+ }
125125+}
126126+127127+128128+pub fn encode_ws_msg_from_server(ws_msg_from_server: WsMsgFromServer) -> json.Json {
129129+ case ws_msg_from_server {
130130+ Greeting(greeting:) -> json.object([
131131+ #("type", json.string("greeting")),
132132+ #("greeting", json.string(greeting)),
133133+ ])
134134+ RegisterPrecheckResponse(ok:, why:) -> json.object([
135135+ #("type", json.string("register_precheck_response")),
136136+ #("ok", json.bool(ok)),
137137+ #("why", json.string(why)),
138138+ ])
139139+ AuthenticationSuccess(username:, token:) -> json.object([
140140+ #("type", json.string("authentication_success")),
141141+ #("username", json.string(username)),
142142+ #("token", json.string(token)),
143143+ ])
144144+ AuthenticationFailure -> json.object([
145145+ #("type", json.string("authentication_failure")),
146146+ ])
147147+ TimeLineResponse(timeline_name:, timeline_id:, items:, total_count:, page:, has_more:) -> json.object([
148148+ #("type", json.string("time_line_response")),
149149+ #("timeline_name", json.string(timeline_name)),
150150+ #("timeline_id", json.string(timeline_id)),
151151+ #("items", json.array(items, json.string)),
152152+ #("total_count", json.int(total_count)),
153153+ #("page", json.int(page)),
154154+ #("has_more", json.bool(has_more)),
155155+ ])
156156+ OwnUserInformationResponse(username:, email:, avatar:, uuid:, unread_notifications:) -> json.object([
157157+ #("type", json.string("own_user_information_response")),
158158+ #("username", json.string(username)),
159159+ #("email", json.string(email)),
160160+ #("avatar", case avatar {
161161+ None -> json.null()
162162+ Some(value) -> json.preprocessed_array([
163163+ json.string(value.0),
164164+ json.string(value.1),
165165+ ])
166166+ }),
167167+ #("uuid", json.string(uuid)),
168168+ #("unread_notifications", json.int(unread_notifications)),
169169+ ])
170170+ Undecodable -> json.object([
171171+ #("type", json.string("undecodable")),
172172+ ])
173173+ }
174174+}
175175+176176+/// Message types for websocket communication from client to server.
177177+pub type WsMsgFromClient {
178178+ Introduction(client_kind: String, try_revive: option.Option(String))
179179+ OwnUserInformationRequest
180180+ LoginAuthenticationRequest(email_username: String, password: String)
181181+ RegisterRequest(email: String, username: String, password: String)
182182+ TimeLineRequest(timeline_name: String, page: Int)
183183+ RegisterPrecheck(
184184+ email: String,
185185+ username: String,
186186+ // Password only once? Yes, the equal password check is done in the view/update themselves.
187187+ password: String,
188188+ )
189189+ PostContentRequest(post_id: String)
190190+}
191191+192192+193193+pub fn encode_ws_msg_from_client(message: WsMsgFromClient) -> json.Json {
194194+ case message {
195195+ Introduction(client_kind:, try_revive:) -> json.object([
196196+ #("type", json.string("introduction")),
197197+ #("client_kind", json.string(client_kind)),
198198+ #("try_revive", case try_revive {
199199+ None -> json.null()
200200+ Some(token) -> json.string(token)
201201+ }),
202202+ ])
203203+ OwnUserInformationRequest ->
204204+ json.object([#("type", json.string("own_user_information_request"))])
205205+ LoginAuthenticationRequest(email_username, password) ->
206206+ json.object([
207207+ #("type", json.string("login_authentication_request")),
208208+ #("email_username", json.string(email_username)),
209209+ #("password", json.string(password)),
210210+ ])
211211+212212+ RegisterRequest(email, username, password) ->
213213+ json.object([
214214+ #("type", json.string("register_request")),
215215+ #("email", json.string(email)),
216216+ #("username", json.string(username)),
217217+ #("password", json.string(password)),
218218+ ])
219219+ RegisterPrecheck(email, username, password) ->
220220+ json.object([
221221+ #("type", json.string("register_precheck")),
222222+ #("email", json.string(email)),
223223+ #("username", json.string(username)),
224224+ #("password", json.string(password)),
225225+ ])
226226+ TimeLineRequest(timeline_name:, page:) ->
227227+ json.object([
228228+ #("type", json.string("timeline_request")),
229229+ #("by_name", json.string(timeline_name)),
230230+ #("page", json.int(page)),
231231+ ])
232232+ PostContentRequest(post_id:) -> {
233233+ json.object([
234234+ #("type", json.string("post_view_request")),
235235+ #("post_id", json.string(post_id)),
236236+ ])
237237+ }
238238+ }
239239+}
240240+241241+pub fn ws_msg_from_client_decoder() -> decode.Decoder(WsMsgFromClient) {
242242+ use variant <- decode.field("type", decode.string)
243243+ case variant {
244244+ "introduction" -> {
245245+ use client_kind <- decode.field("client_kind", decode.string)
246246+ use try_revive <- decode.optional_field(
247247+ "try_revive",
248248+ None,
249249+ decode.optional(decode.string),
250250+ )
251251+ decode.success(Introduction(client_kind:, try_revive:))
252252+ }
253253+ "own_user_information_request" -> decode.success(OwnUserInformationRequest)
254254+ "login_authentication_request" -> {
255255+ use email_username <- decode.field("email_username", decode.string)
256256+ use password <- decode.field("password", decode.string)
257257+ decode.success(LoginAuthenticationRequest(email_username:, password:))
258258+ }
259259+ "register_request" -> {
260260+ use email <- decode.field("email", decode.string)
261261+ use username <- decode.field("username", decode.string)
262262+ use password <- decode.field("password", decode.string)
263263+ decode.success(RegisterRequest(email:, username:, password:))
264264+ }
265265+ "time_line_request" -> {
266266+ use timeline_name <- decode.field("timeline_name", decode.string)
267267+ use page <- decode.field("page", decode.int)
268268+ decode.success(TimeLineRequest(timeline_name:, page:))
269269+ }
270270+ "register_precheck" -> {
271271+ use email <- decode.field("email", decode.string)
272272+ use username <- decode.field("username", decode.string)
273273+ use password <- decode.field("password", decode.string)
274274+ decode.success(RegisterPrecheck(email:, username:, password:))
275275+ }
276276+ "post_content_request" -> {
277277+ use post_id <- decode.field("post_id", decode.string)
278278+ decode.success(PostContentRequest(post_id:))
279279+ }
280280+ _ -> decode.failure(OwnUserInformationRequest, "WsMsgFromClient")
281281+ }
282282+}