big squish

This commit is contained in:
Swrup 2024-05-29 19:16:48 +02:00
parent fae867b35b
commit 55d2abefb4
124 changed files with 6931 additions and 8393 deletions

View file

@ -1,4 +1,4 @@
version=0.24.1
version=0.27.0
assignment-operator=end-line
break-cases=fit
break-fun-decl=wrap

View file

@ -26,3 +26,18 @@ that can resolve to a geographical position.
[AGPL-or-later]
[AGPL-or-later]: ./LICENSE.md
# TODO
- post nb limit
- make it a web-app instead
- rework style, full screen map
- dream -> drame
- good moderation/admin system
- all of [issues](https://git.zapashcanon.fr/zapashcanon/permap/issues)
- imagemagick: WARNING: The convert command is deprecated in IMv7
- db error on login fail instead of clean login error msg
- login fail on login with email?

View file

@ -1,3 +0,0 @@
(documentation
(package permap)
(mld_files index))

View file

@ -1,19 +0,0 @@
{0 permap}
{{:https://TODO} permap} is an {{:https://ocaml.org} OCaml} library/executable to TODO.
{1:api API}
{!modules:
Permap
}
{1:private_api Private API}
You shouldn't have to use any of these modules, they're used internally only.
{!modules:
TODO
}

View file

@ -1,4 +1,7 @@
(lang dune 2.8)
(lang dune 3.0)
(using menhir 2.1)
(generate_opam_files true)
(implicit_transitive_deps false)
@ -6,9 +9,13 @@
(license AGPL-3.0-or-later)
(authors "swrup <swrup@protonmail.com>" "Léo Andrès <contact@ndrs.fr>")
(authors
"swrup <swrup@protonmail.com>"
"Léo Andrès <contact@ndrs.fr>")
(maintainers "Léo Andrès <contact@ndrs.fr>")
(maintainers
"swrup <swrup@protonmail.com>"
"Léo Andrès <contact@ndrs.fr>")
(source
(uri git+https://git.zapashcanon.fr/zapashcanon/permap.git))
@ -19,43 +26,40 @@
(documentation https://doc.zapashcanon.fr/permap)
(generate_opam_files true)
(package
(name permap)
(synopsis "OCaml library/executable to TODO")
(description "permap is an OCaml library/executable to TODO.")
(tags
(permap forum map local-knownledge ecology permaculture plant))
(imageboard forum map leaflet single-page-application functional-reactive-programming))
(depends
dream
lwt
yojson
brr
leaflet
js_of_ocaml
uuidm
scfg
crunch
safepass
omd
lambdasoup
bos
brr
caqti
caqti-driver-sqlite3
conan
conan-database
crunch
data-encoding
digestif
directories
dream
dream-pure
emile
fmt
fpath
lambdasoup
omd
htmlit
js_of_ocaml
leaflet
lwt
note
safepass
scfg
uri
uuidm
yojson
(alcotest :with-test)
(re :with-test)
(ocamlformat :with-dev-setup)
prelude
(ocaml
(>= 4.08))))
(>= 5.1))))

View file

@ -1,3 +0,0 @@
(executable
(name main)
(modules main))

View file

@ -1 +0,0 @@
let () = Format.printf "TODO@."

View file

@ -2,47 +2,51 @@
opam-version: "2.0"
synopsis: "OCaml library/executable to TODO"
description: "permap is an OCaml library/executable to TODO."
maintainer: ["Léo Andrès <contact@ndrs.fr>"]
maintainer: ["swrup <swrup@protonmail.com>" "Léo Andrès <contact@ndrs.fr>"]
authors: ["swrup <swrup@protonmail.com>" "Léo Andrès <contact@ndrs.fr>"]
license: "AGPL-3.0-or-later"
tags: [
"permap" "forum" "map" "local-knownledge" "ecology" "permaculture" "plant"
"imageboard"
"forum"
"map"
"leaflet"
"single-page-application"
"functional-reactive-programming"
]
homepage: "https://git.zapashcanon.fr/zapashcanon/permap"
doc: "https://doc.zapashcanon.fr/permap"
bug-reports: "https://git.zapashcanon.fr/zapashcanon/permap/issues"
depends: [
"dune" {>= "2.8"}
"dream"
"lwt"
"yojson"
"brr"
"leaflet"
"js_of_ocaml"
"uuidm"
"scfg"
"crunch"
"safepass"
"omd"
"lambdasoup"
"dune" {>= "3.0"}
"bos"
"brr"
"caqti"
"caqti-driver-sqlite3"
"conan"
"conan-database"
"crunch"
"data-encoding"
"digestif"
"directories"
"dream"
"dream-pure"
"emile"
"fmt"
"fpath"
"lambdasoup"
"omd"
"htmlit"
"js_of_ocaml"
"leaflet"
"lwt"
"note"
"safepass"
"scfg"
"uri"
"uuidm"
"yojson"
"ocaml" {>= "4.08"}
"alcotest" {with-test}
"re" {with-test}
"ocamlformat" {with-dev-setup}
"prelude"
"ocaml" {>= "5.1"}
"odoc" {with-doc}
]
build: [

334
src/api.ml Normal file
View file

@ -0,0 +1,334 @@
open Syntax
open Err
(* TODO server/client shared routes and types *)
(* used to get url param/session field and convert to int if needed
not actually useful *)
type _ t =
| User_id : string t
| Thread_id : int t
| Post_id : int t
| User_image_id : string t
| Post_image_id : int t
let str : type a. a t -> string = function
| User_id -> "user_id"
| Thread_id -> "thread_id"
| Post_id -> "post_id"
| User_image_id -> "image_id"
| Post_image_id -> "image_id"
let url_param : type a. Dream.request -> a t -> a Err.result =
let to_int s = int_of_string_opt s |> Option.to_result ~none:Err.Not_found in
fun request kind ->
let s = Dream.param request (str kind) in
match kind with
| User_id -> Ok s
| User_image_id -> Ok s
| Thread_id -> to_int s
| Post_id -> to_int s
| Post_image_id -> to_int s
let session_user_id : Dream.request -> string option =
fun request -> Dream.session_field request (str User_id)
let set_session_user_id : Dream.request -> string -> unit Lwt.t =
fun request v -> Dream.set_session_field request (str User_id) v
let handle_invalid_form form =
match form with
| `Expired _ | `Wrong_session _ -> Error Bad_form
| `Ok _ | `Many_tokens _ | `Missing_token _ | `Invalid_token _
| `Wrong_content_type ->
(* usually indicate either bugs or attacks *)
Error Bad_form_suspicious
let get_logged_user request =
match session_user_id request with
| None -> Error Unauthorized
| Some id -> User.get_user id
(* TODO
better system for permissions? *)
let check_is_admin request =
let* user = get_logged_user request in
if user.user_is_admin then Ok user else Error Forbidden
let write_session request =
let open Types in
let+ user_private =
match session_user_id request with
| None -> Ok None
| Some user_id -> Result.map Option.some (User.get_user_private user_id)
in
let valid_for = float_of_int Config.csrf_lifetime in
let session =
{ user_private
; csrf_token = Dream.csrf_token ~valid_for request
; csrf_time_limit = Unix.time () +. valid_for
}
in
Json_data.Write.session session
let handle_login ~login ~password request =
let*! u = User.login ~login ~password in
let%lwt () = Dream.invalidate_session request in
let%lwt () = set_session_user_id request u.user_id in
Lwt.return @@ write_session request
module GET = struct
let catalog _request =
let+ catalog = Post.get_catalog () in
Json_data.Write.catalog catalog
let thread_w_reply request =
let* thread_id = url_param request Thread_id in
let+ v = Post.get_thread_w_reply thread_id in
Json_data.Write.thread_w_reply v
let post request =
let* post_id = url_param request Post_id in
let+ v = Post.get_post post_id in
Json_data.Write.post v
let admin request =
let* _user = check_is_admin request in
let+ reports = Moderation.get_reports_all () in
Json_data.Write.reports reports
let user_page request =
let* user_id = url_param request User_id in
let+ user = User.get_user user_id in
Json_data.Write.user user
let session request = write_session request
end
module POST = struct
let new_thread request =
let*! user = get_logged_user request in
let%lwt form = Dream.multipart request in
Lwt.return
@@
match form with
| `Ok
[ ("alt", [ (_, alt) ])
; ("comment", [ (_, comment) ])
; ("file", file)
; ("lat-input", [ (_, lat) ])
; ("lng-input", [ (_, lng) ])
; ("subject", [ (_, subject) ])
] -> (
match (Float.of_string_opt lat, Float.of_string_opt lng) with
| None, _ | _, None -> Error Bad_form
| Some lat, Some lng ->
let* image_data =
match file with
| [] -> Ok None
| _ :: _ :: _ -> Error Bad_form
| [ (image_name, image_content) ] ->
let image_data = (image_name, alt, image_content) in
Ok (Some image_data)
in
let+ v =
Post.make_thread ~comment ~image_data ~subject ~lat ~lng user
in
Json_data.Write.thread_w_reply v )
| form -> handle_invalid_form form
let reply request =
let*! user = get_logged_user request in
let%lwt form = Dream.multipart request in
Lwt.return
@@
match form with
| `Ok
[ ("alt", [ (_, alt) ]); ("comment", [ (_, comment) ]); ("file", file) ]
->
let* parent_thread =
let* thread_id = url_param request Thread_id in
Post.get_thread thread_id
in
let* image_data =
match file with
| [] -> Ok None
| _ :: _ :: _ -> Error Bad_form
| [ (image_name, image_content) ] ->
let image_data = (image_name, alt, image_content) in
Ok (Some image_data)
in
let* post = Post.make_post ~comment ~image_data ~parent_thread user in
let+ v = Post.get_thread_w_reply post.Types.parent_t_id in
Json_data.Write.thread_w_reply v
| form -> handle_invalid_form form
let login request =
let%lwt form = Dream.multipart request in
match form with
| `Ok [ ("login", [ (_, login) ]); ("password", [ (_, password) ]) ] -> (
(* TODO move all check like this to User/Post *)
let*! b = Moderation.is_banished login in
match b with
| true -> Lwt.return (Error (Unprocessable "YOU ARE BANISHED"))
| false -> handle_login ~login ~password request )
| form -> Lwt.return @@ handle_invalid_form form
let logout request =
let*! _user = get_logged_user request in
let%lwt form = Dream.multipart request in
match form with
| `Ok [] ->
let%lwt () = Dream.invalidate_session request in
Lwt.return @@ write_session request
| form -> Lwt.return @@ handle_invalid_form form
let delete request =
let*! user = get_logged_user request in
let%lwt form = Dream.multipart request in
Lwt.return
@@
match form with
| `Ok [] ->
let* post_id = url_param request Post_id in
let* post = Post.get_post post_id in
let+ () = Post.delete ~user post_id in
Json_data.Write.post post
| form -> handle_invalid_form form
let report request =
let*! user = get_logged_user request in
let%lwt form = Dream.multipart request in
Lwt.return
@@
match form with
| `Ok [ ("reason", [ (_, reason) ]) ] ->
let* post_id = url_param request Post_id in
let* () =
Moderation.make_report ~reporter_user_id:user.user_id ~reason post_id
in
let+ my_reports =
match user.user_is_admin with
| false -> Moderation.get_reports_made_by user.user_id
| true -> Moderation.get_reports_all ()
in
Json_data.Write.reports my_reports
| form -> handle_invalid_form form
let admin_ignore request =
let*! _user = check_is_admin request in
let%lwt form = Dream.multipart request in
Lwt.return
@@
match form with
| `Ok [] ->
let* post_id = url_param request Post_id in
let* () = Moderation.delete_report post_id in
let+ v = Moderation.get_reports_all () in
Json_data.Write.reports v
| form -> handle_invalid_form form
let admin_delete request =
let*! user = check_is_admin request in
let%lwt form = Dream.multipart request in
Lwt.return
@@
match form with
| `Ok [] ->
let* post_id = url_param request Post_id in
let* post = Post.get_post post_id in
let+ () = Post.delete ~user post_id in
Json_data.Write.post post
| form -> handle_invalid_form form
let admin_banish request =
let*! _user = check_is_admin request in
let%lwt form = Dream.multipart request in
Lwt.return
@@
match form with
| `Ok [] ->
let* evil_id = url_param request User_id in
let* evil = User.get_user evil_id in
let+ () = Moderation.banish evil_id in
Json_data.Write.user evil
| form -> handle_invalid_form form
let profile request =
let*! user = get_logged_user request in
let user_id = user.user_id in
let%lwt form = Dream.multipart request in
Lwt.return
@@
match form with
| `Ok [ ("bio", [ (_, bio) ]) ] ->
let* () = User.update_bio user_id bio in
write_session request
| `Ok [ ("nick", [ (_, nick) ]) ] ->
let* () = User.update_nick user_id nick in
write_session request
| `Ok [ ("delete-avatar", _) ] ->
let* () = User.delete_avatar user_id in
write_session request
| `Ok
[ ("alt", [ (None, alt) ]); ("file", [ (image_name, image_content) ]) ]
->
let image_data = (image_name, alt, image_content) in
let* () = User.upload_avatar user_id image_data in
write_session request
| form -> handle_invalid_form form
(*TODO re-ask for password for account settings *)
let account request =
let*! user = get_logged_user request in
let user_id = user.user_id in
let%lwt form = Dream.multipart request in
match form with
| `Ok [ ("delete-account", _) ] -> (
(* TODO ask for confirmation *)
match User.delete_user user_id with
| Error _ as e -> Lwt.return e
| Ok () ->
let%lwt () = Dream.invalidate_session request in
Lwt.return
@@
(*let msg = "Your account was deleted" in*)
write_session request )
| `Ok [ ("email", [ (_, email) ]) ] ->
Lwt.return
@@
(*let msg = "Your email was updated!" in*)
let* () = User.update_email user_id email in
write_session request
| `Ok
[ ("confirm-new-password", [ (_, confirm_password) ])
; ("new-password", [ (_, password) ])
] ->
Lwt.return
@@
let* () =
if String.equal password confirm_password then
User.update_password user_id password
(*let msg = "Your password was updated!" in*)
else Error (Unprocessable "Password confirmation does not match")
in
write_session request
| form -> Lwt.return @@ handle_invalid_form form
let register request =
let*! () =
(* TODO move all check like this to User/Post *)
if Config.open_registration then Ok ()
else Error (Unprocessable "registration is not open")
in
let%lwt form = Dream.multipart request in
match form with
| `Ok
[ ("email", [ (_, email) ])
; ("nick", [ (_, nick) ])
; ("password", [ (_, password) ])
] -> (
match User.register ~email ~nick ~password with
| Error _ as e -> Lwt.return e
| Ok () -> handle_login ~login:email ~password request )
| form -> Lwt.return @@ handle_invalid_form form
end

View file

@ -1,94 +0,0 @@
module App_id = struct
let qualifier = "org"
let organization = "Permap"
let application = "permap"
end
module Project_dirs = Directories.Project_dirs (App_id)
let data_dir =
match Project_dirs.data_dir with
| None -> failwith "can't compute data directory"
| Some data_dir -> data_dir
let config_dir =
match Project_dirs.config_dir with
| None -> failwith "can't compute configuration directory"
| Some config_dir -> config_dir
let config =
let filename = Filename.concat config_dir "config.scfg" in
if not @@ Sys.file_exists filename then
failwith
@@ Format.sprintf "configuration file `%s` does not exist, please create it"
filename;
Dream.log "config file: %s" filename;
match Scfg.Parse.from_file filename with
| Error e -> failwith e
| Ok config -> config
let open_registration =
match Scfg.Query.get_dir "open_registration" config with
| None -> true
| Some open_registration -> (
match Scfg.Query.get_param 0 open_registration with
| Error e -> failwith e
| Ok "true" -> true
| Ok "false" -> false
| Ok _unknown ->
failwith "invalid `open_registration` value in configuration file" )
let () = Dream.log "open_registration: %b" open_registration
let port =
match Scfg.Query.get_dir "port" config with
| None -> 8080
| Some port -> (
match Scfg.Query.get_param 0 port with
| Error e -> failwith e
| Ok n -> (
try
let n = int_of_string n in
if n < 0 then raise (Invalid_argument "negative port number");
n
with Invalid_argument _msg ->
failwith "invalid `port` value in configuration file" ) )
let () = Dream.log "port: %d" port
let hostname =
match Scfg.Query.get_dir "hostname" config with
| None -> Format.sprintf "localhost:%d" port
| Some hostname ->
Result.fold ~error:failwith ~ok:Fun.id (Scfg.Query.get_param 0 hostname)
let () = Dream.log "hostname: %s" hostname
let log =
match Scfg.Query.get_dir "log" config with
| None -> true
| Some log -> (
match Scfg.Query.get_param 0 log with
| Error e -> failwith e
| Ok "true" -> true
| Ok "false" -> false
| Ok _unknown -> failwith "invalid `log` value in configuration file" )
let () = Dream.log "log: %b" log
let get_dirs name =
let dirs = Scfg.Query.get_dirs name config in
List.map
(fun dir ->
Result.fold ~error:failwith ~ok:Fun.id (Scfg.Query.get_param 0 dir) )
dirs
let admins = get_dirs "admin"
let categories = List.sort_uniq compare (get_dirs "category")
let random_state = Random.State.make_self_init ()
let () = Random.set_state random_state

411
src/assets/css/style.css Normal file
View file

@ -0,0 +1,411 @@
/* TODO
* - nice color palette
* - nice fonts
* ... */
:root {
--bg: #e8eaf6;
--bg-nav: #feafd6;
--bg-nav-hover: color-mix(in srgb, var(--bg-nav), black 15%);
--heavy-text: black;
--text: #333333;
--light-text: #5a5a5a;
--quote: #FFB300;
--bg-post: #C5E1A5;
--border-post: #9dd162;
--bg-post-highlight: #9dd162;
--border-form: #FFB300;
--bg-form: #FCE4EC;
--bg-error-popup: red;
--border-error-popup: red;
--bg-id: DodgerBlue;
--bg-id-hover: red;
--bg-id-remote: gray;
--bg-id-remote-loading: #FCE4EC;
--bg-id-remote-not-found: black;
--bg-id-remote-ready: blue;
}
/* unset default */
ul {
list-style-type: none;
padding: 0;
margin-block: 0;
}
body {
margin: 0;
color: var(--light-text);
background-color: var(--bg);
font-size: 18px;
display: grid;
grid-template-rows: 3fr 97fr;
height: 100vh;
}
body * {
max-height: 100%;
}
nav {
background-color: var(--bg-nav);
display: flex;
justify-content: space-between;
}
nav div {
display: flex;
flex-direction: row;
}
.logout-btn {
all: unset;
outline: revert;
}
.logout-btn:hover {
cursor: pointer;
}
nav a, nav button,
.sub-nav a, .sub-nav button {
display: flex;
align-items: center;
text-decoration: none;
color: var(--heavy-text);
transition: all 0.3s ease;
padding-inline: 2vw;
}
nav a:hover, nav button:hover,
.sub-nav a:hover, .sub-nav button:hover {
background-color: var(--bg-nav-hover);
}
/* todo: use sub-grid? */
.home-page {
display: grid;
grid-template-columns: 60fr 40fr ;
width: 100%;
height: 100%;
}
.home-left, .home-right {
position: relative;
width: 100%;
height: 100%;
}
#map {
position: sticky !important;
height: 100vh;
width: 100%;
top: 0;
left: 0;
}
.home-left-navigation-div {
display: flex;
margin-bottom: 7vh;
justify-content: flex-end;
margin-right: 7vw;
}
.map-btn-div {
display: flex;
position: absolute;
bottom: 0;
margin-bottom: 7vh;
justify-content: flex-end;
right: 0;
margin-right: 7vw;
z-index: 999;
}
.new-thread-view {
margin-inline: 1vw;
margin-block: 3vw;
}
/* css trick to have a dropdown menu on click */
.dropdown {
position: relative;
display: grid;
grid-template-rows: 1fr;
grid-auto-rows: 0;
.dropdown-content, .dropdown-content-placeholder {
visibility: hidden;
display: flex;
flex-direction: column;
}
.dropdown-content {
transition: 0.2s ease-out;
z-index: 100000;
position: absolute;
top: 100%;
left: 0;
}
li {
background-color: var(--bg);
}
&:focus-within .dropdown-content {
visibility: visible;
}
.dropdown-arrow {
/* need to be block element to apply transform */
display: inline-block;
transition: 0.2s ease-out;
}
&:focus-within .dropdown-arrow {
transform: rotate(90deg);
}
.dropdown-open-btn, .dropdown-close-btn {
all: unset;
cursor: pointer;
user-select: none;
transition: 0.2s ease-out;
}
.dropdown-open-btn {
/* gap to add space between arrow and label */
display: flex;
gap: 3ch;
align-items: center;
}
.dropdown-open-btn:hover, .dropdown-open-btn:focus {
color: var(--bg-id-hover);
}
.dropdown-close-btn {
display: none;
position: absolute;
top: 0;
left: 0;
opacity: 0;
z-index: 99;
}
&:focus-within .dropdown-close-btn:not(:focus) {
display: inline-block;
min-width: 100%;
}
}
.thread-view {
display: flex;
flex-direction: column;
height: 100%;
}
.sub-nav {
display: flex;
flex-direction: row;
justify-content: space-between;
border-bottom: 1px solid black;
}
#bottom {
margin-top: auto;
}
.thread {
margin-inline: 1vw;
margin-block: 3vw;
}
.thread-subject {
color: var(--light-text);
font-size: 25px;
padding-left: 3vw;
padding-bottom: 1vh;
}
.thread-replies {
color: var(--light-text);
font-size: 20px;
display: flex;
flex-direction: column;
gap: 0.7vh;
}
.post {
background-color: var(--bg-post);
border: 1px solid var(--border-post);
border-top: none;
border-left: none;
padding: 5px;
padding-left: 10px;
width: fit-content;
max-width: 100%;
}
.post-info {
display: flex;
flex-direction: row;
gap: 0.2em;
align-items: center;
margin-bottom: 5px;
}
.post-info * {
text-align: center
}
.post-replies {
display: flex;
gap: 0.2em;
}
.post-id, .post-id-quote {
all: unset;
cursor: revert;
/* revert default focus ring */
outline: revert;
outline-offset: 3px;
background-color: var(--bg-id);
padding: 2px;
text-align: center;
transition: 0.2s ease-out;
display: inline-block;
height: calc(1lh - 2px);
border-radius: 6px;
}
.post-id-quote {
border-radius: 12px;
}
.post-id:hover, .post-id-quote:hover,
.post-id:focus, .post-id-quote:focus {
background-color: var(--bg-id-hover);
}
.post-id-quote.remote {
background-color: var(--bg-id-remote);
}
.post-id-quote.remote.loading {
background-color: var(--bg-id-remote-loading);
}
.post-id-quote.remote.not-found {
background-color: var(--bg-id-remote-not-found);
}
.post-id-quote.remote.ready {
background-color: var(--bg-id-remote-ready);
}
.post-author-nick, .post-link-to-self {
text-decoration: none;
color: unset;
font-style: italic;
transition: 0.2s ease-out;
}
.post-author-nick:hover, .post-link-to-self:hover,
.post-author-nick:focus, .post-link-to-self:focus {
color: var(--bg-id-hover);
}
.post-content {
display: flex;
gap: 10px;
}
/* TODO use image dim? better max-size? */
.post-image-div {
}
.post-image {
max-width: 90vw;
height: auto;
}
.post-image-small {
max-width: 30vw;
max-height: 30vh;
}
.post-comment {
color: var(--text);
padding-top: 10px;
overflow-wrap: break-word;
}
.line-quote {
color: var(--quote);
}
.selected, .highlighted {
background-color: var(--bg-post-highlight);
}
.open-reply-popup-btn {
/* TODO */
}
.reply-popup {
display: table;
position: fixed;
right: 1vw;
top: 40vh;
background-color: var(--bg-form);
border: 2px solid var(--border-form);
padding: 5px;
z-index: 999990;
}
.reply-popup-dragzone {
display: flex;
justify-content: end;
cursor: move;
}
.close-reply-popup-btn {
line-height: 0.5lh;
}
.reply-popup-content {
display: flex;
flex-direction: column;
gap: 1em;
label {
display: block;
}
textarea {
width: 40ch;
height: 15ch;
}
& > div:last-child {
display: flex;
justify-content: center;
margin-top: 1em;
}
}
.error-popup {
display: table;
position: fixed;
right: 1vw;
bottom: 1vh;
padding: 5px;
z-index: 999999;
background-color: var(--bg-error-popup);
border: 2px solid var(--border-error-popup);
border-radius: 12px;
}
.error-popup-dragzone {
display: flex;
justify-content: end;
cursor: move;
}
.close-error-popup-btn {
/* TODO
* - always have good contrast
* - better style
* same for reply-form */
line-height: 0.5lh;
}
.error-popup-content {
padding: 2vw;
font-size: 18px;
color: white;
}
.hidden {
visibility: hidden;
}
.off {
display: none;
}

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 696 B

After

Width:  |  Height:  |  Size: 696 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 618 B

After

Width:  |  Height:  |  Size: 618 B

Before After
Before After

8
src/assets/js/dune Normal file
View file

@ -0,0 +1,8 @@
(rule
(target client.js)
(deps
(file ../../client/main.bc.js))
(action
(with-stdout-to
%{target}
(cat ../../client/main.bc.js))))

View file

@ -1,425 +0,0 @@
open Syntax
open Caqti_request.Infix
open Caqti_type
type moderation_action =
| Ignore
| Delete
| Banish
let moderation_action_to_string = function
| Ignore -> "ignore"
| Delete -> "delete"
| Banish -> "banish"
let moderation_action_from_string = function
| "ignore" -> Some Ignore
| "delete" -> Some Delete
| "banish" -> Some Banish
| _ -> None
type thread_data =
{ subject : string
; lng : float
; lat : float
}
type post =
{ id : string
; emojid : string
; parent_id : string
; date : float
; user_id : string
; nick : string
; comment : string
; image_info : (string * string) option
; tags : string list
; replies : string list
; citations : string list
}
type t =
| Op of thread_data * post
| Post of post
let () =
let tables =
[| (unit ->. unit)
"CREATE TABLE IF NOT EXISTS post_user (post_id TEXT, user_id TEXT, \
PRIMARY KEY(post_id), FOREIGN KEY(user_id) REFERENCES user(user_id) \
ON DELETE CASCADE)"
; (* one row for each thread, with thread's data *)
(unit ->. unit)
"CREATE TABLE IF NOT EXISTS thread_info (thread_id TEXT, subject \
TEXT, lat FLOAT, lng FLOAT, FOREIGN KEY(thread_id) REFERENCES \
post_user(post_id) ON DELETE CASCADE)"
; (* map thread and reply to the thread *)
(unit ->. unit)
"CREATE TABLE IF NOT EXISTS thread_post (thread_id TEXT, post_id \
TEXT, FOREIGN KEY(thread_id) REFERENCES post_user(post_id) ON DELETE \
CASCADE, FOREIGN KEY(post_id) REFERENCES post_user(post_id) ON \
DELETE CASCADE)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS post_replies (post_id TEXT, reply_id \
TEXT, FOREIGN KEY(post_id) REFERENCES post_user(post_id) ON DELETE \
CASCADE, FOREIGN KEY(reply_id) REFERENCES post_user(post_id) ON \
DELETE CASCADE)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS post_citations (post_id TEXT, cited_id \
TEXT, FOREIGN KEY(post_id) REFERENCES post_user(post_id) ON DELETE \
CASCADE, FOREIGN KEY(cited_id) REFERENCES post_user(post_id) ON \
DELETE CASCADE)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS post_date (post_id TEXT, date FLOAT, \
FOREIGN KEY(post_id) REFERENCES post_user(post_id) ON DELETE \
CASCADE)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS post_comment (post_id TEXT, comment TEXT, \
FOREIGN KEY(post_id) REFERENCES post_user(post_id) ON DELETE \
CASCADE)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS post_tags (post_id TEXT, tag TEXT, \
FOREIGN KEY(post_id) REFERENCES post_user(post_id) ON DELETE \
CASCADE)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS report (user_id TEXT, reason TEXT, date \
FLOAT,post_id TEXT, FOREIGN KEY(post_id) REFERENCES \
post_user(post_id) ON DELETE CASCADE, FOREIGN KEY(user_id) \
REFERENCES user(user_id) ON DELETE CASCADE)"
|]
in
if
Array.exists Result.is_error
(Array.map (fun query -> Db.exec query ()) tables)
then Dream.error (fun log -> log "can't create babillard's tables")
module Q = struct
let upload_report =
Db.exec
@@ (tup4 string string float string ->. unit)
"INSERT INTO report VALUES (?,?,?,?)"
let get_reports =
Db.collect_list
@@ (unit ->* tup4 string string float string) "SELECT * FROM report"
let upload_post_id =
Db.exec
@@ (tup2 string string ->. unit) "INSERT INTO post_user VALUES (?,?)"
let upload_thread_info =
Db.exec
@@ (tup4 string string float float ->. unit)
"INSERT INTO thread_info VALUES (?,?,?,?)"
let upload_thread_post =
Db.exec
@@ (tup2 string string ->. unit) "INSERT INTO thread_post VALUES (?,?)"
let upload_post_reply =
Db.exec
@@ (tup2 string string ->. unit) "INSERT INTO post_replies VALUES (?,?)"
let upload_post_comment =
Db.exec
@@ (tup2 string string ->. unit) "INSERT INTO post_comment VALUES (?,?)"
let upload_post_tag =
Db.exec
@@ (tup2 string string ->. unit) "INSERT INTO post_tags VALUES (?,?)"
let upload_post_date =
Db.exec @@ (tup2 string float ->. unit) "INSERT INTO post_date VALUES (?,?)"
let get_post_user_id =
Db.find
@@ (string ->! string) "SELECT user_id FROM post_user WHERE post_id=?"
let get_post_comment =
Db.find
@@ (string ->! string) "SELECT comment FROM post_comment WHERE post_id=?"
let get_post_tags =
Db.collect_list
@@ (string ->* string) "SELECT tag FROM post_tags WHERE post_id=?"
let get_post_date =
Db.find @@ (string ->! float) "SELECT date FROM post_date WHERE post_id=?"
let get_post_citations =
Db.collect_list
@@ (string ->* string) "SELECT post_id FROM post_citations WHERE post_id=?"
let get_post_replies =
Db.collect_list
@@ (string ->* string) "SELECT reply_id FROM post_replies WHERE post_id=?"
let get_thread_posts =
Db.collect_list
@@ (string ->* string) "SELECT post_id FROM thread_post WHERE thread_id=?"
let count_thread_posts =
Db.find
@@ (string ->! int)
"SELECT COUNT(post_id) FROM thread_post WHERE thread_id=?"
let get_is_post =
Db.find
@@ (string ->! string)
"SELECT post_id FROM post_user WHERE post_id=? LIMIT 1"
let get_post_thread =
Db.find
@@ (string ->! string)
"SELECT thread_id FROM thread_post WHERE post_id=? LIMIT 1"
let get_thread_info =
Db.find
@@ (string ->! tup3 string float float)
"SELECT subject,lat,lng FROM thread_info WHERE thread_id=?"
let get_threads =
Db.collect_list @@ (unit ->* string) "SELECT thread_id FROM thread_info"
let delete_post =
Db.exec @@ (string ->. unit) "DELETE FROM post_user WHERE post_id=?"
end
let ignore_report =
Db.exec @@ (string ->. unit) "DELETE FROM report WHERE post_id=?"
(*TODO switch to markdown !*)
(* insert html into the comment, and keep tracks of citations :
-wraps lines starting with ">" with a <span class="quote">
-make raw posts uuid into links
(*TODO fix bad link if post is in other thread*)
-keeps tracks of every post cited in this comment
- add <br> at each line *)
let parse_comment comment =
let citations = ref [] in
let pp_word fmt w =
let trim_w = String.trim w in
(* '>' is '&gt;' after html_escape *)
if String.length trim_w >= 8 then
let sub_w = String.sub trim_w 8 (String.length trim_w - 8) in
if
String.starts_with ~prefix:{|&gt;&gt;|} trim_w
&& Option.is_some (Uuidm.of_string sub_w)
then begin
citations := sub_w :: !citations;
Format.fprintf fmt {|<a href="#%s">%s</a>|} sub_w w
end
else Format.pp_print_string fmt w
else Format.pp_print_string fmt w
in
let pp_line fmt l =
let trim_w = String.trim l in
(*insert quote*)
let words = String.split_on_char ' ' l in
if
String.starts_with ~prefix:{|&gt;|} trim_w
&& not (String.starts_with ~prefix:{|&gt;&gt;|} trim_w)
then
Format.fprintf fmt {|<span class="quote">%a</span>|}
(Format.pp_print_list ~pp_sep:Format.pp_print_space pp_word)
words
else Format.pp_print_list ~pp_sep:Format.pp_print_space pp_word fmt words
in
let comment = String.trim comment in
let lines = String.split_on_char '\n' comment in
(*insert <br>*)
let comment =
Format.asprintf "%a"
(Format.pp_print_list
~pp_sep:(fun fmt () -> Format.fprintf fmt "@\n<br>")
pp_line )
lines
in
(* remove duplicate cited_id *)
let citations = List.sort_uniq String.compare !citations in
(comment, citations)
let upload_post ~image post =
let thread_data, reply =
match post with
| Op (thread_data, reply) -> (Some thread_data, reply)
| Post reply -> (None, reply)
in
let { id; parent_id; date; user_id; comment; tags; citations; _ } = reply in
let* () = Q.upload_post_id (id, user_id) in
let* () = Q.upload_post_comment (id, comment) in
let* () = Q.upload_post_date (id, date) in
let* () = Q.upload_thread_post (parent_id, id) in
let* () =
match image with None -> Ok () | Some image -> Image.upload image id
in
match unwrap_list (fun tag -> Q.upload_post_tag (id, tag)) tags with
| Error _e as e -> e
| Ok _ -> (
match
unwrap_list (fun cited_id -> Q.upload_post_reply (cited_id, id)) citations
with
| Error _e as e -> e
| Ok _ ->
let* () =
match thread_data with
| None -> Ok ()
| Some { subject; lng; lat } ->
Q.upload_thread_info (id, subject, lat, lng)
in
Ok id )
let build_reply ~comment ~image_info ~tag_list ?parent_id user_id =
let comment = Dream.html_escape comment in
let id = Uuidm.to_string (Uuidm.v4_gen App.random_state ()) in
(* parent_id is None if this reply is supposed to be a new thread *)
let parent_id = Option.value parent_id ~default:id in
if Option.is_none (Uuidm.of_string parent_id) then Error "invalid thread id"
else if String.length comment > 10000 then Error "invalid comment"
else if List.length tag_list > 30 then Error "too much tags"
else if List.exists (fun tag -> String.length tag > 100) tag_list then
Error "tag too long"
else if Option.is_none image_info && String.length (String.trim comment) = 0
then Error "Your post must contain an image or a comment"
else
let tag_list =
List.map String.lowercase_ascii
@@ List.sort_uniq String.compare
@@ List.filter (( <> ) "")
@@ List.map String.trim
@@ List.map Dream.html_escape tag_list
in
let date = Unix.time () in
let comment, citations = parse_comment comment in
let* nick = User.get_nick user_id in
let* emojid = Emojid.make id in
let reply =
{ id
; emojid
; parent_id
; date
; user_id
; nick
; comment
; image_info
; tags = tag_list
; replies = []
; citations
}
in
Ok reply
let build_op ~comment ~image_info ~tag_list ~categories ~subject ~lat ~lng
user_id =
let subject = Dream.html_escape subject in
if List.exists (fun s -> not (List.mem s App.categories)) categories then
Error "Invalid category"
else
let tag_list = categories @ tag_list in
(* TODO latlng validation? *)
let is_valid_latlng = true in
if not is_valid_latlng then Error "Invalid coordinate"
else if String.length subject > 600 then Error "Invalid subject"
else
let* reply = build_reply ~comment ~image_info ~tag_list user_id in
Ok ({ subject; lng; lat }, reply)
let make_post ~comment ?image_input ~tags ~op_or_reply_data user_id =
let tag_list = String.split_on_char ',' tags in
let* image, image_info =
match image_input with
| None -> Ok (None, None)
| Some image_input ->
let* image = Image.make_image image_input in
Ok (Some image, Some (image.name, image.alt))
in
let* post =
match op_or_reply_data with
| `Reply_data parent_id ->
let* reply =
build_reply ~comment ~image_info ~tag_list ~parent_id user_id
in
Ok (Post reply)
| `Op_data (categories, subject, lat, lng) ->
let* thread_data, reply =
build_op ~comment ~image_info ~tag_list ~categories ~subject ~lat ~lng
user_id
in
Ok (Op (thread_data, reply))
in
upload_post ~image post
(* true if post is an op too *)
let post_exist id = Result.is_ok (Q.get_is_post id)
let get_post id =
let* emojid = Emojid.get id in
let* parent_id = Q.get_post_thread id in
let* user_id = Q.get_post_user_id id in
let* nick = User.get_nick user_id in
let* comment = Q.get_post_comment id in
let* date = Q.get_post_date id in
let* image_info = Image.get_info id in
let* tags = Q.get_post_tags id in
let* replies = Q.get_post_replies id in
let* citations = Q.get_post_citations id in
let reply =
{ id
; emojid
; parent_id
; date
; user_id
; nick
; comment
; image_info
; tags
; replies
; citations
}
in
Ok reply
let get_thread_data id =
let* subject, lat, lng = Q.get_thread_info id in
Ok { subject; lat; lng }
let get_op id =
let* thread_data = get_thread_data id in
let* post = get_post id in
Ok (thread_data, post)
let get_posts ids = unwrap_list get_post ids
let get_ops ids = unwrap_list get_op ids
let try_delete_post ~user_id id =
let* post = get_post id in
if post.user_id = user_id || User.is_admin user_id then Q.delete_post id
else Error "You can only delete your posts"
let report ~user_id ~reason id =
if not (post_exist id) then Error "This post exists not"
else if String.length reason > 2000 then Error "Your reason is too long.."
else
let reason = Dream.html_escape reason in
let date = Unix.time () in
Q.upload_report (user_id, reason, date, id)
let get_reports () =
let* reports = Q.get_reports () in
let* posts =
unwrap_list (fun (_reporter_id, _reason, _date, id) -> get_post id) reports
in
(* add reporter_nick to reports so we can display it *)
let* reports =
unwrap_list
(fun (reporter_id, reason, date, id) ->
let* reporter_nick = User.get_nick reporter_id in
Ok (reporter_id, reporter_nick, reason, date, id) )
reports
in
Ok (posts, reports)

View file

@ -1,31 +0,0 @@
let f request =
% let new_thread_button =
% if Option.is_none @@ Dream.session "nick" request then
% Format.sprintf
% {|<a class="btn btn-primary" id="new-thread-button-redirect" href="/login?redirect=%s">New Thread</a>|} (Dream.to_percent_encoded "/")
% else {|<button class="btn btn-primary on" id="new-thread-button">New Thread</button>|}
% in
<script type="text/javascript" src="/assets/js/babillard.js" defer="defer"></script>
<h1>Babillard is love ❤️</h1>
<br />
<div class="row mb-3">
<div class="col-lg-6 col-md-12">
<div id="map"></div>
<br />
<button class="btn btn-primary" id="geolocalize">Geolocalize me</button>
<button class="btn btn-primary off" id="return-button">Return</button>
<%s! new_thread_button %>
</div>
<div class="col-lg-6 col-md-12">
<div class="thread-preview on" id="thread-preview"></div>
<div class="new-thread off" id="new-thread">
<h2>New thread</h2>
<span id="new-thread-info">
Click the map and make a new thread:
</span>
<br />
<%s! Post_form.f None request %>
</div>
</div>
</div>

48
src/caqti_db.ml Normal file
View file

@ -0,0 +1,48 @@
open Caqti_request.Infix
let map_err = function
| Error e -> Error (Err.Internal (Db (Caqti_error.show e)))
| Ok _ as ok -> ok
module Db = struct
module Db =
(val Caqti_blocking.connect Config_serv.db_uri |> Caqti_blocking.or_fail)
let exec q v = Db.exec q v |> map_err
let find q v = Db.find q v |> map_err
let find_opt q v = Db.find_opt q v |> map_err
let collect_list q v = Db.collect_list q v |> map_err
let exec_unsafe q v =
match Db.exec q v with
| Error e ->
Dream.error (fun log -> log "%s" (Caqti_error.show e));
exit 1
| Ok () -> ()
let do_transaction f =
let open Syntax in
let* () = Db.start () |> map_err in
match f () with
| Error _ as error ->
let* () = Db.rollback () |> map_err in
error
| Ok v ->
let* () = Db.commit () |> map_err in
Ok v
end
let set_foreign_keys_on = Caqti_type.(unit ->. unit) "PRAGMA foreign_keys = ON"
let create_dream_session =
Caqti_type.(unit ->. unit)
"CREATE TABLE IF NOT EXISTS dream_session (id TEXT PRIMARY KEY, label TEXT \
NOT NULL, expires_at REAL NOT NULL, payload TEXT NOT NULL)"
let () =
Db.exec_unsafe set_foreign_keys_on ();
Db.exec_unsafe create_dream_session ();
()

17
src/caqti_db.mli Normal file
View file

@ -0,0 +1,17 @@
open Err
module Db : sig
val exec : ('a, unit, [< `Zero ]) Caqti_request.t -> 'a -> unit result
val find : ('a, 'b, [< `One ]) Caqti_request.t -> 'a -> 'b result
val find_opt :
('a, 'b, [< `One | `Zero ]) Caqti_request.t -> 'a -> 'b option result
val collect_list :
('a, 'b, [ `Many | `One | `Zero ]) Caqti_request.t -> 'a -> 'b list result
val exec_unsafe : ('a, unit, [< `Zero ]) Caqti_request.t -> 'a -> unit
val do_transaction : (unit -> 'a result) -> 'a result
end

View file

@ -1,8 +0,0 @@
let f content =
<script type="text/javascript" src="/assets/js/catalog.js" defer="defer"></script>
<h1>Catalog:</h1>
<br />
<div class="row mb-3">
<%s! content %>
</div>

207
src/client/client_types.ml Normal file
View file

@ -0,0 +1,207 @@
open Types
type ('a, 'b) wrap = ('a, 'b) Page.wrap
module Fragment = struct
type t =
| Empty
| Top
| Bottom
| Id of (int, int) wrap
let unwrap_id = function Page.Loading v | Not_found v -> v | Ready v -> v
let to_string = function
| Empty -> ""
| Top -> "top"
| Bottom -> "bottom"
| Id v ->
let id = unwrap_id v in
string_of_int id
let of_string s =
match s with
| "" -> Ok Empty
| "top" -> Ok Top
| "bottom" -> Ok Bottom
| s -> (
match int_of_string_opt s with
| None -> Fmt.error "invalid fragment format `%s`" s
| Some id -> Ok (Id (Loading id)) )
let get_ready_value v =
match v with
| Empty | Top | Bottom -> Some (to_string v)
| Id (Loading _) | Id (Not_found _) -> None
| Id (Ready _) -> Some (to_string v)
end
module Post_form_data = struct
(* TODO (?) have a more genral thing for every form *)
(* store input data of reply and new thread form
both form share the same data:
text in comment on reply form will show up on new thread form too
wraped in module because record field conflict *)
type t =
{ subject : string
; comment : string
; file : string option
; alt : string option
; is_open : bool
; latlng : (float * float) option
}
let empty =
{ subject = ""
; comment = ""
; file = None
; alt = None
; is_open = false
; latlng = None
}
end
type meth =
| GET
| POST
type response =
{ meth : meth
; url : string
; status : int
; status_text : string
; body : string
}
(* https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch#exceptions *)
(* https://developer.mozilla.org/en-US/docs/Web/API/Response/text#exceptions *)
type network_error =
| Fetch_err of string
| Body_err of string
| Read_err of string * response
(* error type for interactions with server *)
type error =
| Network_err of network_error
| Err_response of Err.t
type map_action =
| Move_end of (float * float * int)
| Zoom_end of (float * float * int)
| Click_latlng of (float * float)
| Click_marker of Types.post_id
| Geoloc_start
| Geoloc_pos of Brr_io.Geolocation.Pos.t
| Geoloc_err of Brr_io.Geolocation.Error.t
type form_action =
| Form_open
| Form_close
| Form_insert_quote of post_id
| Form_comment of string
| Form_file of string option
| Form_alt of string option
| Form_subject of string
| Form_latlng of (float * float) option
| Form_reset
(* post-quote's (x,y,w,h)
needed to compute quickview position *)
type rect = float * float * float * float
type action =
| Navigation_event of (Page.t option * Fragment.t)
| Post_form_change of form_action
| Map_input of map_action
| Submit_event of (Form_kind.wrapped * Brr.El.t)
| Quickview_change of (rect * post_id) option
| Image_click of post_id
| Clear_error
type data_update =
| Post_update of post
| Thread_update of Thread_w_reply.t
| Catalog_update of thread list
| User_update of user
| Reports_update of report list
| Session_update of session
(* printer/util *)
let pp_meth fmt = function GET -> Fmt.pf fmt "GET" | POST -> Fmt.pf fmt "POST"
let pp_response fmt r =
Fmt.pf fmt
{|{ meth: `%a`; url: `%s`; status code: `%d`; status text: `%s`; body:`%s`}@.|}
pp_meth r.meth r.url r.status r.status_text r.body
let pp_network_error fmt err =
match err with
| Fetch_err s -> Fmt.pf fmt "network fetch error `%s`" s
| Body_err s -> Fmt.pf fmt "network read body error `%s`" s
| Read_err (s, r) ->
Fmt.pf fmt "network read error `%s` on response `%a`" s pp_response r
let pp_error fmt err =
match err with
| Network_err e -> pp_network_error fmt e
| Err_response e -> Err.pp fmt e
let pp_map_action fmt a =
Fmt.pf fmt "map ";
match a with
| Move_end (lat, lng, zoom) ->
Fmt.pf fmt "move end `(%f, %f, %d)`" lat lng zoom
| Zoom_end (lat, lng, zoom) ->
Fmt.pf fmt "zoom end `(%f, %f, %d)`" lat lng zoom
| Click_latlng (lat, lng) -> Fmt.pf fmt "click latlng `(%f, %f)`" lat lng
| Click_marker post_id -> Fmt.pf fmt "click marker `%d`" post_id
| Geoloc_start -> Fmt.pf fmt "geoloc start"
| Geoloc_pos pos ->
let open Brr_io.Geolocation.Pos in
Fmt.pf fmt "geoloc pos `(%f, %f)`" (latitude pos) (longitude pos)
| Geoloc_err err ->
let open Brr_io.Geolocation.Error in
Fmt.pf fmt "geoloc error, code `%d` message `%s`" (code err)
(message err |> Jstr.to_string)
let pp_form_action fmt a =
Fmt.pf fmt "form ";
match a with
| Form_open -> Fmt.pf fmt "open"
| Form_close -> Fmt.pf fmt "close"
| Form_insert_quote post_id -> Fmt.pf fmt "insert quote `%d`" post_id
| Form_comment s -> Fmt.pf fmt "comment `%s`" s
| Form_file o -> Fmt.pf fmt "file `%s`" (Option.value ~default:"none" o)
| Form_alt o -> Fmt.pf fmt "alt `%s`" (Option.value ~default:"none" o)
| Form_subject s -> Fmt.pf fmt "subject `%s`" s
| Form_latlng o -> (
match o with
| None -> Fmt.pf fmt "latlng `none`"
| Some (lat, lng) -> Fmt.pf fmt "latlng `(%f, %f)`" lat lng )
| Form_reset -> Fmt.pf fmt "reset"
let pp_action fmt = function
| Navigation_event (opt, frag) ->
let s =
match opt with
| None -> "none"
| Some p -> p |> Page.to_uri |> Brr.Uri.to_jstr |> Jstr.to_string
in
Fmt.pf fmt "navigation event `(%s, %s)`" s (Fragment.to_string frag)
| Post_form_change a -> Fmt.pf fmt "post form change `%a`" pp_form_action a
| Map_input a -> Fmt.pf fmt "map input `%a`" pp_map_action a
| Submit_event (W kind, _el) ->
Fmt.pf fmt "submit event `%s`" (Form_kind.name kind)
| Quickview_change _opt -> Fmt.pf fmt "quickview change"
| Image_click post_id -> Fmt.pf fmt "image click `%d`" post_id
| Clear_error -> Fmt.pf fmt "clear error"
let pp_data_update fmt a =
match a with
| Post_update v -> Fmt.pf fmt "post update `%d`" v.id
| Thread_update v -> Fmt.pf fmt "thread update `%d`" v.op.id
| Catalog_update _l -> Fmt.pf fmt "catalog update"
| User_update u -> Fmt.pf fmt "user update `%s`" u.user_id
| Reports_update _l -> Fmt.pf fmt "report update"
| Session_update _session -> Fmt.pf fmt "session update"

129
src/client/db.ml Normal file
View file

@ -0,0 +1,129 @@
open Types
let session : session option ref = ref None
let update_session (v : session) =
session := Some v;
()
let get_session () =
match !session with
| None -> Fmt.failwith "called get_session with uninitialized session"
| Some v -> v
let post_db : (post_id, post) Hashtbl.t = Hashtbl.create 0x1000
let add_post (v : post) =
Hashtbl.replace post_db v.id v;
()
let find_post id =
match Hashtbl.find_opt post_db id with None -> None | Some v -> Some v
let post_db_404 : (post_id, unit) Hashtbl.t = Hashtbl.create 0x100
let post_is_404 id = Hashtbl.mem post_db_404 id
let thread_is_404 id = Hashtbl.mem post_db_404 id
let user_db_404 : (user_id, unit) Hashtbl.t = Hashtbl.create 0x100
let user_is_404 id = Hashtbl.mem user_db_404 id
let catalog : thread list ref = ref []
let update_catalog (v : thread list) =
catalog := v;
()
let get_catalog () = !catalog
let thread_w_reply : Thread_w_reply.t option ref = ref None
let update_thread_w_reply (o : Thread_w_reply.t option) =
Hashtbl.clear post_db;
thread_w_reply := o;
Option.iter (fun v -> List.iter add_post v.Thread_w_reply.reply_l) o;
()
let find_thread_w_reply id =
match !thread_w_reply with
| None -> None
| Some v -> ( match v.op.id = id with false -> None | true -> Some v )
let reports : report list ref = ref []
let update_reports (v : report list) =
reports := v;
()
let get_reports () = !reports
let user : user option ref = ref None
let update_user (v : user option) =
user := v;
()
let find_user id =
match !user with
| None -> None
| Some v -> (
match String.equal v.user_id id with false -> None | true -> Some v )
let clear () =
session := None;
update_catalog [];
update_thread_w_reply None;
update_reports [];
Hashtbl.clear post_db;
Hashtbl.clear post_db_404;
update_user None;
()
let add_post_404 id =
(* in case post is a thread we have to remove id + potential reply_l *)
let to_delete_l =
match find_thread_w_reply id with
| Some v -> List.map (fun p -> p.id) v.reply_l
| None -> [ id ]
in
let filter get_id l =
(* O(n^2) ~~ *)
List.filter (fun v -> not @@ List.mem (get_id v) to_delete_l) l
in
update_catalog (get_catalog () |> filter (fun v -> v.op.id));
update_thread_w_reply
( match find_thread_w_reply id with
| Some _ -> None
| None -> (
match !thread_w_reply with
| None -> None
| Some v ->
let v = { v with reply_l = filter (fun v -> v.id) v.reply_l } in
Some v ) );
update_reports (!reports |> filter (fun r -> r.reported_post.id));
Hashtbl.remove post_db id;
Hashtbl.add post_db_404 id ();
()
let add_thread_404 = add_post_404
let add_user_404 id =
let session = get_session () in
let session =
match session.user_private with
| Some u when String.equal u.user_id id ->
(* dead session here *)
{ session with user_private = None }
| _ -> session
in
update_session session;
begin
match find_user id with Some _ -> update_user None | None -> ()
end;
()

17
src/client/dune Normal file
View file

@ -0,0 +1,17 @@
(executable
(name main)
(modules :standard)
(libraries
config_impl ; virtual
shared
comment
leaflet
note
note.brr
brr
fmt
unix
prelude)
(modes js)
(flags
(:standard -open Prelude)))

8
src/client/events.ml Normal file
View file

@ -0,0 +1,8 @@
open Note
open Client_types
let (actions : action event), send_action = E.create ()
let (data_updates : data_update event), send_data_update = E.create ()
let (errors : error event), send_error = E.create ()

50
src/client/form_kind.ml Normal file
View file

@ -0,0 +1,50 @@
(* TODO server/client shared routes and types *)
open Types
type _ t =
| Home : Thread_w_reply.t t
| Register : session t
| Login : session t
| Logout : session t
| Profile : session t
| Account : session t
| Thread : post_id -> Thread_w_reply.t t
| Delete : post_id -> post t
| Report :
post_id
-> report list t (* only reports made by user, or all if user is admin *)
| Admin_ignore : post_id -> report list t
| Admin_delete : post_id -> post t
| Admin_banish : user_id -> user t
type wrapped = W : 'a t -> wrapped [@@unboxed]
let name : type a. a t -> string = function
| Home -> "new-thread"
| Register -> "register"
| Login -> "login"
| Logout -> "logout"
| Profile -> "profile"
| Account -> "account"
| Thread _ -> "post"
| Delete _ -> "delete-post"
| Report _ -> "report-post"
| Admin_ignore _ -> "admin-ignore"
| Admin_delete _ -> "admin-delete"
| Admin_banish _ -> "admin-banish"
let action : type a. a t -> string = function
| Home -> "/"
| Register -> "/register"
| Login -> "/login"
| Logout -> "/logout"
| Profile -> "/profile"
| Account -> "/account"
| Thread id -> Fmt.str "/thread/%d" id
| Delete id -> Fmt.str "/delete/%d" id
| Report id -> Fmt.str "/report/%d" id
| Admin_ignore id -> Fmt.str "/admin/ignore/%d" id
| Admin_delete id -> Fmt.str "/admin/delete/%d" id
| Admin_banish id -> Fmt.str "/admin/banish/%s" id
let action k = Fmt.str "/api%s" (action k)

366
src/client/html.ml Normal file
View file

@ -0,0 +1,366 @@
open Brr
open Note
open Note_brr
open Types
open Client_types
open Page
open Model
open Util
open Html_util
module Header = struct
let mk t_s =
let left = El.div ~at:[ class' "nav-left" ] [ mk_page_link Home ] in
let right session =
let dropmenu user =
let class_prefix = "settings" in
let label = user.user_nick in
let at_title = "Settings" in
let mk_content () =
[ mk_page_link Profile
; mk_page_link Account
; Html_form.mk_logout ()
; mk_page_link About
]
in
mk_dropdown_menu ~class_prefix ~label ~at_title ~placeholder:true
mk_content
in
let l =
match Option.map user_private_to_public session.user_private with
| None -> List.map mk_page_link [ About; Register; Login ]
| Some u when u.user_is_admin ->
[ mk_page_link (Admin (Loading ())); dropmenu u ]
| Some u -> [ dropmenu u ]
in
El.div ~at:[ class' "nav-right" ] l
in
let children = S.map (fun t -> [ left; right t.session ]) t_s in
let el = El.nav ~at:[ id "top" ] [] in
Elr.def_children el children;
el
let f t_s =
let header = El.header [ mk t_s ] in
header
end
module Home = struct
let left t_s =
let new_thread_view =
(* TODO try to find better class names *)
let new_thread_form_div =
El.div
~at:[ class' "new-thread-form-div" ]
[ Html_form.new_thread_el t_s ]
in
El.div
~at:[ class' "new-thread-view" ]
[ h2 "New thread"
; El.span
~at:[ class' "new-thread-info" ]
[ el_txt "Click the map and make a new thread:" ]
; new_thread_form_div
]
in
let thread_view = Html_thread.f t_s in
let new_thread_link = Html_thread.new_thread_link_el t_s in
let return_link = El.a ~at:[ href (to_path Home) ] [ el_txt "Return" ] in
let navigation_div =
El.div
~at:[ class' "home-left-navigation-div" ]
[ new_thread_link; return_link ]
in
let mode k = S.map (is_page_kind k) t_s in
def_on (mode New_thread) new_thread_view;
def_off (mode Thread) navigation_div;
def_on (mode Thread) thread_view;
def_off (mode New_thread) new_thread_link;
def_on (mode New_thread) return_link;
let el =
El.div
~at:[ class' "home-left" ]
[ navigation_div; new_thread_view; thread_view ]
in
el
let f t_s =
let left_el = left t_s in
let right_el = Leaflet_map.f t_s in
let el = El.div ~at:[ class' "home-page" ] [ left_el; right_el ] in
def_on
(S.map
(fun t ->
is_page_kind Home t || is_page_kind New_thread t
|| is_page_kind Thread t )
t_s )
el;
el
end
module About = struct
let f t_s =
let l = [ h1 "TODO about page" ] in
let el = mk_page About t_s l in
el
end
module Register = struct
let f t_s =
let l = [ h1 "Register"; Html_form.mk_register () ] in
let el = mk_page Register t_s l in
el
end
module Login = struct
let f t_s =
let l = [ h1 "Login"; Html_form.mk_login () ] in
let el = mk_page Login t_s l in
el
end
module Admin = struct
let mk t_s t =
match get_user_admin t with
| None -> []
| Some _user -> (
match t.page with
| Home | New_thread | Thread _ | About | Register | Login | Profile
| Account | Delete _ | Report _ | User _ ->
[]
| Admin (Loading ()) -> loading_el
| Admin (Not_found ()) -> not_found_el
| Admin (Ready reports) ->
let forms =
match reports with
| [] ->
[ el_txt "Report list is empty!~"
; El.br ()
; el_txt "good job! ( ๑>ᴗ<๑ )"
]
| reports ->
(* TODO add reported_post_parent_t_id to report type? *)
List.map
(fun report ->
let post = report.reported_post in
let post_view = Html_post.post_view t_s post in
let span_info_on_report =
let s =
Fmt.str "From: %s, Reason: %s" report.reporter_nick
report.reason
in
El.span [ el_txt s ]
in
let forms =
El.div
Html_form.
[ admin_ignore post.id
; admin_delete post.id
; admin_banish post.poster_id
]
in
El.div
~at:[ class' "report" ]
[ post_view; span_info_on_report; forms ] )
reports
in
let reports_div = El.div ~at:[ class' "reports-div" ] forms in
[ h1 "Administration board"; reports_div ] )
let f t_s =
let el = mk_page Admin t_s [] in
Elr.def_children el (S.map (mk t_s) t_s);
el
end
module Profile = struct
let mk t =
match get_user t with
| None -> []
| Some user ->
let public_profile_link =
El.p
[ el_txt "Check your "
; mk_page_link ~label:"public profile" (User (Loading user.user_id))
]
in
let forms = Html_form.profile user in
[ h1 "Profile settings"; public_profile_link ] @ forms
let f t_s =
let el = mk_page Profile t_s [] in
Elr.def_children el (S.map mk t_s);
el
end
module Account = struct
let mk t =
match get_user_private t with
| None -> []
| Some user_private ->
let forms = Html_form.account user_private in
h1 "Account settings" :: forms
let f t_s =
let el = mk_page Account t_s [] in
Elr.def_children el (S.map mk t_s);
el
end
module User = struct
let mk t =
match t.page with
| Home | New_thread | Thread _ | About | Register | Login | Admin _
| Profile | Account | Delete _ | Report _ ->
[]
| User (Loading _user_id) -> loading_el
| User (Not_found _user_id) -> not_found_el
| User (Ready user) ->
let bio = El.div [ El.blockquote (Html_util.insert_br user.bio) ] in
let img =
match user.avatar_info with
| None -> []
| Some info ->
let alt_at =
if String.equal "" info.alt then []
else [ alt info.alt; name info.name; title info.alt ]
in
let at =
[ Fmt.kstr src "/user/%s/avatar" user.user_id
; class' "img-thumbnail"
]
@ alt_at
in
[ El.img ~at () ]
in
h1 user.user_nick :: bio :: img
let f t_s =
let el = mk_page User t_s [] in
Elr.def_children el (S.map mk t_s);
el
end
module Delete = struct
let mk t_s t =
match get_user t with
| None -> []
| Some user -> (
match t.page with
| Home | New_thread | Thread _ | About | Register | Login | Admin _
| Profile | Account | Report _ | User _ ->
[]
| Delete (Loading _id) -> loading_el
| Delete (Not_found _id) -> not_found_el
| Delete (Ready post) -> (
match String.equal post.poster_id user.user_id with
| false -> (* TODO error can not delete other's posts *) []
| true ->
let post_view = Html_post.post_view t_s post in
let form = Html_form.delete post in
[ post_view; form ] ) )
let f t_s =
let el = mk_page Delete t_s [] in
Elr.def_children el (S.map (mk t_s) t_s);
el
end
module Report = struct
let mk t_s t =
match get_user t with
| None -> []
| Some _user -> (
match t.page with
| Home | New_thread | Thread _ | About | Register | Login | Admin _
| Profile | Account | Delete _ | User _ ->
[]
| Report (Loading _id) -> loading_el
| Report (Not_found _id) -> not_found_el
| Report (Ready post) ->
let post_view = Html_post.post_view t_s post in
let form = Html_form.report post in
[ post_view; form ] )
let f t_s =
let el = mk_page Report t_s [] in
Elr.def_children el (S.map (mk t_s) t_s);
el
end
module Error_popup = struct
let mk container_el opt =
match opt with
| None -> []
| Some error ->
let dragzone =
let close_btn =
El.button ~at:[ class' "close-error-popup-btn" ] [ el_txt "X" ]
in
hold_on close_btn Ev.click (fun _ev -> Events.send_action Clear_error);
El.div ~at:[ class' "error-popup-dragzone" ] [ close_btn ]
in
Html_form.Dragzone.f ~dragzone container_el;
let content =
El.div
~at:[ class' "error-popup-content" ]
[ El.span [ el_txt (Fmt.str "%a" Client_types.pp_error error) ] ]
in
[ dragzone; content ]
let f t_s =
let el = El.div ~at:[ class' "error-popup" ] [] in
Elr.def_children el (S.map (fun t -> mk el t.error) t_s);
def_off (S.map (fun t -> Option.is_none t.error) t_s) el;
el
end
module Main = struct
let f t_s =
let l =
List.map
(fun f -> f t_s)
[ Home.f
; About.f
; Register.f
; Login.f
; Admin.f
; Profile.f
; Account.f
; User.f
; Delete.f
; Report.f
; Error_popup.f
]
in
let main = El.v (str "main") l in
main
end
let def_page_title t_s =
let set_title page =
let s =
match page with
| Thread (Loading _) | User (Loading _) -> "loading"
| Thread (Not_found _) | User (Not_found _) -> "not found"
| Thread (Ready v) -> v.subject
| User (Ready u) -> u.user_nick
| page -> (
match to_kind page with
| New_thread -> "new thread"
| kind -> Kind.to_string kind )
in
Fmt.str "%s | Permap" s |> String.capitalize_ascii |> Jstr.v
|> Document.set_title G.document
in
S.map (fun t -> t.page) t_s |> S.changes |> hold_endless set_title;
(* init *)
let k = (S.value t_s).page in
set_title k;
()
let f t_s =
let header_el = Header.f t_s in
let main_el = Main.f t_s in
def_page_title t_s;
[ header_el; main_el ]

391
src/client/html_form.ml Normal file
View file

@ -0,0 +1,391 @@
open Brr
open Note
open Note_brr
open Types
open Client_types
open Util
let handle_submit kind form ev =
Fmt.pr "catched form submit event@.";
Ev.prevent_default ev;
Events.send_action (Submit_event (W kind, form));
()
let mk kind ~btn l =
let class_prefix = Form_kind.name kind in
let action = Form_kind.action kind in
let at =
[ Fmt.kstr class' "%s-form " class_prefix
; At.action (str action)
; At.method' (str "POST")
; mk_at "enctype" "multipart/form-data"
]
in
let content = l @ [ El.div [ btn ] ] in
let form = El.form ~at content in
hold_on form Brr_io.Form.Ev.submit (fun ev -> handle_submit kind form ev);
form
(* -- TODO clean up this mess -- *)
let mk_field_unwraped kind ~name ~label ~at =
let type' =
type'
@@
match kind with
| `Text | `Textarea _ -> "text"
| `Password -> "password"
| `File -> "file"
in
let label =
El.label
~at:
[ At.for' (str name); Fmt.kstr id "%s-label" name; class' "form-label" ]
[ el_txt label ]
in
let at =
[ type'
; id name
; At.name (str name)
; class' "form-label"
; Fmt.kstr (mk_at "aria-labelledby") "%s-label" name
]
@ at
in
let item =
match kind with
| `Text | `File | `Password -> El.input ~at ()
| `Textarea content -> El.textarea ~at [ el_txt content ]
in
(label, item)
let mk_field kind ~name ~label ~at =
let label, item = mk_field_unwraped kind ~name ~label ~at in
El.div [ label; item ]
let mk_btn ?(at = []) s =
let at = [ type' "submit"; class' "submit-btn" ] @ at in
El.button ~at [ el_txt s ]
let mk_btn_save () = mk_btn "Save"
let mk_btn_submit () = mk_btn "Submit"
let mk_logout () =
let btn =
let label = "❌ Logout" in
let btn_class = "logount-btn" in
El.button ~at:[ class' btn_class ] [ el_txt label ]
in
mk Logout ~btn []
let mk_register () =
let nick = mk_field `Text ~name:"nick" ~label:"Nickname" ~at:[] in
let email = mk_field `Text ~name:"email" ~label:"Email" ~at:[] in
let password = mk_field `Password ~name:"password" ~label:"Password" ~at:[] in
let btn = mk_btn_submit () in
mk Register ~btn [ nick; email; password ]
let mk_login () =
let nick = mk_field `Text ~name:"login" ~label:"Nickname or email" ~at:[] in
let password = mk_field `Password ~name:"password" ~label:"Password" ~at:[] in
let btn = mk_btn_submit () in
mk Login ~btn [ nick; password ]
let mk_subject_field_unwraped () =
mk_field_unwraped `Text ~name:"subject" ~label:"Subject" ~at:[]
let mk_comment_field_unwraped s =
mk_field_unwraped (`Textarea s) ~name:"comment" ~label:"Comment" ~at:[]
let mk_image_field_unwraped () =
let file_label, file =
mk_field_unwraped `File ~name:"file" ~label:"Add picture"
~at:
[ mk_at "accept"
(String.concat "," (Array.to_list Config.supported_mime_type))
]
in
let alt =
El.div
~at:[ class' "alt-image-input-div" ]
[ mk_field (`Textarea "") ~name:"alt" ~label:"Image desciption" ~at:[] ]
in
((file_label, file), alt)
let mk_image_field () =
let (file_label, file), alt = mk_image_field_unwraped () in
let file_div = El.div [ file_label; file ] in
El.div ~at:[ class' "image-input-div" ] [ file_div; alt ]
(* -------- *)
let sync_field input ~on form_action =
hold_on input Ev.input (fun _ev ->
let s = El.prop El.Prop.value input |> Jstr.to_string in
Events.send_action (Post_form_change (form_action s)) );
Elr.set_prop El.Prop.value ~on input;
()
let mk_comment_div t_s =
let open Model in
let label, textarea = mk_comment_field_unwraped "" in
let () =
let on = S.map (fun t -> t.post_form.comment |> Jstr.v) t_s |> S.changes in
let send s = Client_types.Form_comment s in
sync_field textarea ~on send
in
let focus_e =
S.map
(fun t ->
(* take reply_form here and not reply_form.is_open
so focus turn on when textarea content changes (quote insertion) *)
t.post_form )
t_s
|> S.changes
|> E.filter_map (fun rf ->
match rf.Post_form_data.is_open with
| false -> None
| true -> Some true )
in
Elr.set_has_focus ~on:focus_e textarea;
El.div ~at:[ class' "comment-input-div" ] [ label; textarea ]
let mk_image_div t_s =
let open Model in
let (file_label, file), alt = mk_image_field_unwraped () in
let () =
let has_file = S.map (fun t -> Option.is_some t.post_form.file) t_s in
Util.def_on has_file alt;
let on =
S.map (fun t -> t.post_form.alt) t_s
|> S.changes |> E.filter_map Fun.id |> E.map Jstr.v
in
let send s =
let opt = if String.equal s "" then None else Some s in
Client_types.Form_alt opt
in
sync_field alt ~on send
in
hold_on file Ev.change (fun _ev ->
let opt =
match El.Input.files file with
| [] -> None
| file :: _l ->
let s = File.name file |> Jstr.to_string in
Some s
in
Events.send_action (Post_form_change (Form_file opt)) );
(* clear image file name if needed *)
let on =
S.map
(fun t ->
match t.post_form.file with
| None -> Some (Jv.to_jstr Jv.null)
| Some _s -> None )
t_s
|> S.changes |> E.filter_map Fun.id
in
Elr.set_prop El.Prop.value ~on file;
let file_div = El.div [ file_label; file ] in
El.div ~at:[ class' "image-input-div" ] [ file_div; alt ]
let new_thread_el t_s =
let open Model in
let subject =
let label, input = mk_subject_field_unwraped () in
let () =
let on =
S.map (fun t -> t.post_form.subject |> Jstr.v) t_s |> S.changes
in
let send s = Client_types.Form_subject s in
sync_field input ~on send
in
El.div ~at:[ class' "subject-input-div" ] [ label; input ]
in
let comment = mk_comment_div t_s in
let image = mk_image_div t_s in
let lat =
El.input ~at:[ type' "hidden"; id "lat-input"; name "lat-input" ] ()
in
let lng =
El.input ~at:[ type' "hidden"; id "lng-input"; name "lng-input" ] ()
in
let latlng_s = S.map (fun t -> t.post_form.latlng) t_s in
Elr.def_at At.Name.value
(latlng_s |> S.map (Option.map fst) |> S.map (Option.map Jstr.of_float))
lat;
Elr.def_at At.Name.value
(latlng_s |> S.map (Option.map snd) |> S.map (Option.map Jstr.of_float))
lng;
let btn =
let at = [ class' "submit-post-btn" ] in
mk_btn ~at "Post"
in
Util.def_disabled (S.map Option.is_none latlng_s) btn;
mk Home ~btn [ subject; comment; image; lat; lng ]
let profile user =
let mk = mk Profile in
let nickname =
let nick =
mk_field `Text ~name:"nick" ~label:"Change nickname"
~at:[ value user.user_nick ]
in
let btn = mk_btn_save () in
let form = mk ~btn [ nick ] in
[ h2 "Nickname"; form ]
in
let bio =
let bio =
mk_field (`Textarea user.bio) ~name:"bio" ~label:"Change your biography"
~at:[]
in
let btn = mk_btn_save () in
let form = mk ~btn [ bio ] in
[ h2 "Biography"; form ]
in
let avatar =
(* TODO
- small preview off current avatar on the left of delete avatar button
- preview of image to be uploaded
- add image preview in new-thread/reply form too*)
let delete =
user.avatar_info
|> Option.map (fun _ ->
let input_el =
El.input
~at:[ type' "hidden"; name "delete-avatar"; value "" ]
()
in
let btn = mk_btn "delete current avatar" in
mk ~btn [ input_el ] )
|> Option.to_list
in
let upload =
let file_el =
mk_field `File ~name:"file" ~label:"Change your avatar"
~at:
[ mk_at "accept"
(String.concat "," (Array.to_list Config.supported_mime_type))
]
in
(* TODO disable alt field if no image; do the same for post form *)
let alt_el =
let content =
Option.fold ~none:"" ~some:(fun img -> img.alt) user.avatar_info
in
mk_field (`Textarea content) ~name:"alt" ~label:"Image desciption"
~at:[]
in
let btn = mk_btn_save () in
[ mk ~btn [ file_el; alt_el ] ]
in
(h2 "Avatar" :: delete) @ upload
in
nickname @ bio @ avatar
let account user_private =
let mk = mk Account in
let email =
let email =
mk_field `Text ~name:"email" ~label:"Email"
~at:[ value user_private.User_private.email ]
in
let btn = mk_btn_save () in
let form = mk ~btn [ email ] in
[ h2 "Change email"; form ]
in
let password =
let pw1 =
mk_field `Password ~name:"new-password" ~label:"New password" ~at:[]
in
let pw2 =
mk_field `Password ~name:"confirm-new-password"
~label:"Confirm new password" ~at:[]
in
let btn = mk_btn_save () in
let form = mk ~btn [ pw1; pw2 ] in
[ h2 "Change password"; form ]
in
let big_delete =
let btn = mk_btn ~at:[ class' "delete-account-btn" ] "DELETE ACCOUNT" in
let form =
mk ~btn
[ El.input ~at:[ type' "hidden"; name "delete-account"; value "" ] () ]
in
[ h2 "Delete account"; form ]
in
email @ password @ big_delete
let delete post =
let btn = mk_btn "DELETE" in
mk (Delete post.id) ~btn []
let report post =
let btn = mk_btn "Report" in
let reason = mk_field `Text ~name:"reason" ~label:"Reason" ~at:[] in
mk (Report post.id) ~btn [ reason ]
let admin_ignore post_id =
let btn = mk_btn "ignore" in
mk (Admin_ignore post_id) ~btn []
let admin_delete post_id =
let btn = mk_btn "DELETE" in
mk (Admin_delete post_id) ~btn []
let admin_banish user_id =
let btn = mk_btn "BANISH" in
mk (Admin_banish user_id) ~btn []
module Dragzone = struct
(* TODO
- send drag_state to model on dragend (mouseup)
need to differentiate which popup we are dragging for this *)
let drag_state = ref None
let on_mousedown dragzone container ev =
match !drag_state with
| Some _ -> Fmt.failwith "Dragzone state error: double mousedown?"
| None ->
let evt = Ev.as_type ev in
let offset_x = El.bound_x container -. Ev.Mouse.client_x evt in
let offset_y = El.bound_y container -. Ev.Mouse.client_y evt in
drag_state := Some (dragzone, container, offset_x, offset_y);
(* css so nothing get highlighted *)
El.set_inline_style (Jstr.v "user-select") (Jstr.v "none") body;
El.set_inline_style (Jstr.v "pointer-events") (Jstr.v "none") body;
El.set_inline_style (Jstr.v "pointer-events") (Jstr.v "auto") dragzone
let on_mousemove ev =
match !drag_state with
| None -> ()
| Some (_dragzone, container, offset_x, offset_y) ->
let evt = Ev.as_type ev in
let x = Ev.Mouse.client_x evt +. offset_x in
let y = Ev.Mouse.client_y evt +. offset_y in
let x = clamp ~min:0. ~max:(window_width () -. El.bound_w container) x in
let y = clamp ~min:0. ~max:(window_height () -. El.bound_h container) y in
El.set_inline_style El.Style.position (Jstr.v "fixed") container;
El.set_inline_style El.Style.left (Fmt.kstr Jstr.v "%fpx" x) container;
El.set_inline_style El.Style.top (Fmt.kstr Jstr.v "%fpx" y) container
let on_mouseup _ev =
match !drag_state with
| None -> ()
| Some (dragzone, _container, _, _) ->
El.set_inline_style (Jstr.v "user-select") (Jstr.v "") body;
El.set_inline_style (Jstr.v "pointer-events") (Jstr.v "") body;
El.set_inline_style (Jstr.v "pointer-events") (Jstr.v "") dragzone;
drag_state := None
let () =
hold_endless_on_window Ev.mousemove on_mousemove;
hold_endless_on_window Ev.mouseup on_mouseup;
()
let f ~dragzone container =
hold_on dragzone Ev.mousedown (fun ev -> on_mousedown dragzone container ev);
()
end

335
src/client/html_post.ml Normal file
View file

@ -0,0 +1,335 @@
open Brr
open Note
open Note_brr
open Types
open Client_types
open Util
let nick post =
El.a
~at:
[ class' "user-link"
; class' "post-author-nick"
; mk_at "data-user-id" post.poster_id
; Fmt.kstr href "/user/%s" post.poster_id
]
[ el_txt post.poster_nick ]
let date post =
let print_date t =
let t = Unix.localtime t in
Fmt.str "%02d-%02d-%02d %02d:%02d" (1900 + t.tm_year) (1 + t.tm_mon)
t.tm_mday t.tm_hour t.tm_min
in
El.span
~at:[ class' "post-date"; mk_at "data-time" (string_of_float post.date) ]
[ el_txt (print_date post.date) ]
(* TODO rm this since we can click the post_id? *)
let link_to_post ?(is_vignette = false) post =
let url =
if is_vignette then Fmt.str "/thread/%d#%d" post.parent_t_id post.id
else Fmt.str "#%d" post.id
in
El.a
~at:[ href url; title "Link to this post"; class' "post-link-to-self" ]
[ el_txt "#" ]
let post_id post =
let el =
let at =
[ class' "post-id"
; title "Reply to this post"
; mk_at "data-id" (string_of_int post.id)
; Fmt.kstr href "#%d" post.id
]
in
El.a ~at [ el_txt (Fmt.str ">>%d" post.id) ]
in
hold_on el Ev.click (fun _ev ->
Events.send_action (Post_form_change Form_open);
Events.send_action (Post_form_change (Form_insert_quote post.id)) );
el
let post_id_quote =
let is_local_link t_s id =
(* list of post currently on the page
(consider thread page case only) *)
let post_l =
match (S.value t_s).Model.page with
| Thread (Ready v) -> v.reply_l
| _ -> []
in
List.find_opt (fun p -> p.id = id) post_l |> Option.is_some
in
let hold_highlight_event el id =
let mouseenter = Evr.on_el Ev.mouseenter Evr.unit el in
let mouseleave = Evr.on_el Ev.mouseleave Evr.unit el in
let focus = Evr.on_el Ev.focus Evr.unit el in
let blur = Evr.on_el Ev.blur Evr.unit el in
let off = E.select [ mouseleave; blur ] |> E.map (fun () -> None) in
let on =
E.select [ mouseenter; focus ]
|> E.map (fun () -> Some (get_bounds el, id))
in
let event = E.select [ off; on ] in
hold_event_on el event (fun opt ->
Events.send_action (Quickview_change opt) );
()
in
fun t_s id ->
let at = [ class' "post-id-quote"; mk_at "data-id" (string_of_int id) ] in
let txt = el_txt (Fmt.str ">>%d" id) in
match is_local_link t_s id with
| true ->
(* simple #%d link *)
let at = [ Fmt.kstr href "#%d" id ] @ at in
let el = El.a ~at [ txt ] in
hold_highlight_event el id;
el
| false ->
(* remote link *)
let at = [ class' "remote" ] @ at in
let container = El.span [] in
hold_highlight_event container id;
let children =
let open Page in
S.map (fun t -> t.Model.quickview) t_s
|> S.changes |> E.filter_map Fun.id
|> E.map (fun (rect, v) ->
let quickview_id = unwrap_post_id v in
fun last_value ->
match quickview_id = id with
| true -> Some (rect, v)
| false -> last_value )
|> S.accum None
|> S.map (function
| None -> [ El.button ~at [ txt ] ]
| Some (_rect, v) -> (
match v with
| Loading _ ->
let at = [ class' "loading" ] @ at in
[ El.button ~at [ txt ] ]
| Not_found _ ->
let at = [ class' "not-found" ] @ at in
[ El.button ~at [ txt ] ]
| Ready p ->
let at =
[ class' "ready"
; Fmt.kstr href "/thread/%d#%d" p.parent_t_id p.id
]
@ at
in
[ El.a ~at [ txt ] ] ) )
in
Elr.def_children container children;
container
let post_menu t_s post =
let mk s =
El.a
~at:
[ Fmt.kstr href "/%s/%d" s post.id
; Fmt.kstr class' "%s-link" s
; mk_at "data-post-id" (string_of_int post.id)
]
[ el_txt (String.capitalize_ascii s) ]
in
let mk_content () =
let delete = mk "delete" in
let report = mk "report" in
let own_post =
S.map Model.get_user t_s
|> S.map (function
| None -> false
| Some u -> String.equal u.user_id post.poster_id )
in
def_on own_post delete;
[ delete; report ]
in
Html_util.mk_dropdown_menu ~class_prefix:"post-info" ~label:""
~at_title:"Post menu" ~placeholder:false mk_content
let backlinks t_s post =
let l = List.map (post_id_quote t_s) post.backlinks in
El.div ~at:[ class' "post-replies" ] l
let image t_s ?(is_vignette = false) post =
match post.image_info with
| None -> None
| Some image -> (
(* TODO show image dimension/name *)
let mk is_small =
let class_small =
if is_small then [ class' "post-image-small" ] else []
in
let sizes =
[ mk_at "width"
(string_of_int (if is_small then image.thumb_w else image.w))
; mk_at "height"
(string_of_int (if is_small then image.thumb_h else image.h))
]
in
let url =
src
@@
if is_small then Fmt.str "/img/s/%d" post.id
else Fmt.str "/img/%d" post.id
in
let at =
class_small @ sizes
@ url
:: [ class' "post-image"
; alt image.alt
; title image.alt
; mk_at "data-id" (string_of_int post.id)
; mk_at "loading" "lazy"
]
in
El.img ~at ()
in
let img_small, img_big = (mk true, mk false) in
let el = El.div ~at:[ class' "post-image-div" ] [ img_small ] in
match is_vignette with
| true -> Some el
| false ->
(* swap img_(small/big) on click *)
hold_on el Ev.click (fun _ev -> Events.send_action (Image_click post.id));
let img_s =
S.map (fun t -> t.Model.opened_image) t_s
|> S.map (function
| Some id when Int.equal id post.id -> [ img_big ]
| Some _ | None -> [ img_small ] )
in
Elr.def_children el img_s;
Some el )
let comment =
let open Comment in
let insert_br_between_lines l =
match l with
| [] -> []
| hd :: tl ->
List.rev
@@ List.fold_left (fun acc x -> x :: [ El.br () ] :: acc) [ hd ] tl
in
let item t_s = function Txt s -> el_txt s | Id i -> post_id_quote t_s i in
let items t_s l = List.map (item t_s) l in
let line t_s = function
| Line l -> items t_s l
| Line_quote l ->
[ El.span ~at:[ class' "line-quote" ] (el_txt ">" :: items t_s l) ]
in
fun t_s comment ->
let content =
List.map (line t_s) comment |> insert_br_between_lines |> List.flatten
in
El.div ~at:[ class' "post-comment" ] content
let info t_s post =
El.div
~at:[ class' "post-info" ]
[ nick post
; date post
; post_id post
; link_to_post post
; post_menu t_s post
; backlinks t_s post
]
let post_view t_s post =
let info = info t_s post in
let content =
let comment = comment t_s post.comment in
let l =
match image t_s post with
| None -> [ comment ]
| Some image -> [ image; comment ]
in
El.div ~at:[ class' "post-content" ] l
in
let at = [ class' "post"; id (string_of_int post.id) ] in
let el = El.div ~at [ info; content ] in
let is_selected =
S.map
(fun t ->
match t.Model.fragment with
| Id v ->
let id = Fragment.unwrap_id v in
post.id = id
| Empty | Top | Bottom -> false )
t_s
in
Elr.def_class (Jstr.v "selected") is_selected el;
let is_highlighted =
S.map (fun t -> t.Model.quickview) t_s
|> S.map (function
| None -> false
| Some (_rect, v) -> Int.equal post.id (Page.unwrap_post_id v) )
in
Elr.def_class (Jstr.v "highlighted") is_highlighted el;
el
module Quickview = struct
open Model
let quickview_class = "quickview-div"
let to_px_jstr x = x |> int_of_float |> Fmt.str "%dpx" |> Jstr.of_string
let is_in_viewport post =
(* find highlighted post DOM element *)
let id = string_of_int post.id in
match find_html_el_by_id id with
| None -> false
| Some el ->
(* check bounds *)
let x, y, w, h = get_bounds el in
let ( <= ) x y = Float.compare x y <= 0 in
0. <= x && 0. <= y
&& x +. w <= window_width ()
&& y +. h <= window_height ()
let f t_s =
let container = El.div ~at:[ class' quickview_class ] [] in
let mk (id_x, id_y, id_w, id_h) post =
if is_in_viewport post then []
else
let quickview = post_view t_s post in
(* ensure we don't have duplicate html id attribute *)
El.set_at At.Name.id (Some (Jstr.v "quickview")) quickview;
(* hack: insert hidden quickview into DOM so we can compute it's bounds
we don't use the viewed post's already in DOM element for this
- it might actually not be in DOM
- it might have it's image opened and size changed *)
El.set_inline_style El.Style.visibility (Jstr.v "hidden") quickview;
El.set_children container [ quickview ];
(* compute quickview position *)
let quickview_x = id_x +. id_w in
let quickview_h = El.bound_h quickview in
let quickview_y = id_y +. (0.5 *. id_h) -. (0.5 *. quickview_h) in
let quickview_y =
clamp ~min:0. ~max:(window_height () -. quickview_h) quickview_y
in
(* undo quickview DOM insertion *)
El.set_inline_style El.Style.visibility (Jstr.v "visible") quickview;
El.remove quickview;
(* set quickview style *)
El.set_inline_style El.Style.position (Jstr.v "fixed") quickview;
El.set_inline_style El.Style.z_index (Jstr.v "99999") quickview;
El.set_inline_style El.Style.left (to_px_jstr quickview_x) quickview;
El.set_inline_style El.Style.top (to_px_jstr quickview_y) quickview;
[ quickview ]
in
let children =
S.map (fun t -> t.quickview) t_s
|> S.map (function
| None -> []
| Some (rect, v) -> (
match v with
| Page.Loading _ | Not_found _ -> []
| Ready post -> mk rect post ) )
in
Elr.def_children container children;
container
end

156
src/client/html_thread.ml Normal file
View file

@ -0,0 +1,156 @@
open Brr
open Note
open Note_brr
open Types
open Page
open Model
open Util
open Html_util
let thread_el_aux t_s w =
match w with
| Loading _id -> loading_el
| Not_found _id -> not_found_el
| Ready (v : Thread_w_reply.t) ->
let subject =
El.div ~at:[ class' "thread-subject" ] [ El.strong [ el_txt v.subject ] ]
in
let reply_l =
let l =
List.sort (fun a b -> Float.compare a.date b.date) v.reply_l
|> List.map (fun p -> Html_post.post_view t_s p)
in
El.div ~at:[ class' "thread-replies" ] l
in
[ subject; reply_l ]
let thread_el t_s w =
let id = Jstr.of_int (unwrap_thread_id w) in
let el =
El.div
~at:[ class' "thread"; At.v (str "data-id") id ]
(thread_el_aux t_s w)
in
el
let reply_popup_el t_s w =
let dragzone =
let close_btn =
El.button ~at:[ class' "close-reply-popup-btn" ] [ el_txt "X" ]
in
hold_on close_btn Ev.click (fun _ev ->
Events.send_action (Post_form_change Form_close) );
El.div ~at:[ class' "reply-popup-dragzone" ] [ close_btn ]
in
let content =
let open Html_form in
let comment = mk_comment_div t_s in
let image = mk_image_div t_s in
let btn = mk_btn "Post" in
let form = mk (Thread (unwrap_thread_id w)) ~btn [ comment; image ] in
let el = El.div ~at:[ class' "reply-popup-content" ] [ form ] in
el
in
let el = El.div ~at:[ class' "reply-popup" ] [ dragzone; content ] in
Html_form.Dragzone.f ~dragzone el;
let is_visible_s = S.map (fun t -> t.post_form.is_open) t_s in
def_on is_visible_s el;
el
let new_thread_link_el t_s =
let mk user =
match user with
| None ->
(* TODO redirect *)
mk_page_link ~label:"Login to post a thread!" Login
| Some _user ->
El.a ~at:[ href (Page.to_path New_thread) ] [ el_txt "New thread" ]
in
let el = El.div ~at:[ class' "new-thread-link-div" ] [] in
let children = S.map get_user t_s |> S.map (fun u -> [ mk u ]) in
Elr.def_children el children;
el
let bump_status_el v =
el_txt
@@
match v with
| Types.Dead -> "Dead thread"
| Locked c ->
Fmt.str "bump order: [%d/%d]\nLocked thread, You cannot reply anymore." c
Config.thread_alive_max_count
| Alive c -> Fmt.str "bump order: [%d/%d]" c Config.thread_alive_max_count
let reply_btn_el t_s w =
let mk user =
match w with
| Loading _ | Not_found _ -> []
| Ready (v : Thread_w_reply.t) ->
let el =
match user with
| None ->
(* TODO redirect *)
mk_page_link ~label:"Login to reply!" Login
| Some _user -> (
match v.bump_status with
| Dead | Locked _ -> bump_status_el v.bump_status
| Alive _bump_order ->
let btn =
let at = [ class' "open-reply-popup-btn" ] in
El.button ~at [ el_txt "Post a reply" ]
in
let is_hidden = S.map (fun t -> t.post_form.is_open) t_s in
Elr.def_class (Jstr.v "hidden") is_hidden btn;
hold_on btn Ev.click (fun _ev ->
Events.send_action (Post_form_change Form_open) );
btn )
in
[ el ]
in
let el = El.div ~at:[ class' "reply-popup-btn-div" ] [] in
let children = S.map get_user t_s |> S.map mk in
Elr.def_children el children;
el
(* need t_s for user + reply form open/close state *)
let nav_el kind t_s w =
let str = match kind with `Top -> "top" | `Bottom -> "bottom" in
let str_inv = match kind with `Top -> "bottom" | `Bottom -> "top" in
let update_el =
El.a ~at:[ href (Page.to_path (Thread w)) ] [ el_txt "Update" ]
in
let at =
match kind with
| `Top ->
(* id="top" is set on nav bar instead *)
[ class' "sub-nav" ]
| `Bottom -> [ class' "sub-nav"; id str ]
in
let el =
El.div ~at
[ new_thread_link_el t_s
; reply_btn_el t_s w
; update_el
; El.a
~at:[ Fmt.kstr href "#%s" str_inv ]
[ Fmt.kstr el_txt "Go to %s" str_inv ]
]
in
el
let mk t_s w =
[ nav_el `Top t_s w
; thread_el t_s w
; reply_popup_el t_s w
; Html_post.Quickview.f t_s
; nav_el `Bottom t_s w
]
let f t_s =
let el = El.div ~at:[ class' "thread-view" ] [] in
let children_s =
S.map get_thread_w_reply t_s
|> S.map (function None -> [] | Some w -> mk t_s w)
in
Elr.def_children el children_s;
el

80
src/client/html_util.ml Normal file
View file

@ -0,0 +1,80 @@
open Brr
open Note
open Model
open Util
let loading_el = [ el_txt "ฅ^•ﻌ•^ฅ loading" ]
let not_found_el = [ el_txt "ฅ^•ﻌ•^ฅ not found" ]
let mk_page_link ?label p =
let open Page in
let href = href (to_path p) in
let k = to_kind p in
let s = Kind.to_string k in
let label =
match label with
| Some label -> label
| None -> (
match Kind.to_emoji k with
| None -> s
| Some emoji -> Fmt.str "%s %s" emoji s )
in
El.a ~at:[ Fmt.kstr class' "%s-link" s; href ] [ el_txt label ]
let is_page_kind k t = Page.(Kind.equal k (to_kind t.page))
let mk_page kind t_s l =
let el =
let at = [ Fmt.kstr class' "%s-page" (Page.Kind.to_string kind) ] in
El.div ~at l
in
let is_on = S.map (is_page_kind kind) t_s in
def_on is_on el;
el
let insert_br s =
match String.split_on_char '\n' s with
| [] -> []
| hd :: tl ->
List.rev
@@ List.fold_left
(fun acc x -> el_txt x :: El.br () :: acc)
[ el_txt hd ]
tl
(* glorious CSS dropdown menu
- need to take mk_content and not just content because El.t are only added one time in DOM
-> or clone content
- need placeholder for correct style *)
let mk_dropdown_menu ~class_prefix ~label ~at_title ~placeholder mk_content =
let mk_btn suffix =
let at =
[ Fmt.kstr class' "%s-dropdown%s" class_prefix suffix
; Fmt.kstr class' "dropdown%s" suffix
]
in
let arrow = El.span ~at:[ class' "dropdown-arrow" ] [ el_txt "" ] in
El.button ~at [ arrow; el_txt label ]
in
let mk_dropdown_content suffix =
let at =
[ Fmt.kstr class' "%s-dropdown-content%s" class_prefix suffix
; Fmt.kstr class' "dropdown-content%s" suffix
]
in
let l = mk_content () |> List.map (fun o -> El.li [ o ]) in
El.ul ~at l
in
let at =
[ Fmt.kstr class' "%s-dropdown" class_prefix
; class' "dropdown"
; At.title (str at_title)
]
in
let l =
mk_btn "-open-btn"
:: (if placeholder then [ mk_dropdown_content "-placeholder" ] else [])
@ [ mk_dropdown_content ""; mk_btn "-close-btn" ]
in
El.div ~at l

199
src/client/leaflet_map.ml Normal file
View file

@ -0,0 +1,199 @@
open Brr
open Note
open Leaflet
open Util
let geoloc_btn =
let s = "geolocalize-btn" in
El.button ~at:[ class' s; id s ] [ el_txt "Geolocalize me" ]
let buttons =
let new_thread_link =
El.a ~at:[ href (Page.to_path New_thread) ] [ el_txt "New thread" ]
in
El.div ~at:[ class' "map-btn-div" ] [ new_thread_link; geoloc_btn ]
let map_el = El.div ~at:[ id "map" ] []
let map = Map.create_from_div map_el
let set_view (lat, lng, zoom) =
let latlng = Latlng.create ~lat ~lng in
let zoom = Some zoom in
Map.set_view latlng ~zoom map;
()
let get_view () =
let latlng = Map.get_center map in
let lat = Latlng.lat latlng in
let lng = Latlng.lng latlng in
let zoom = Map.get_zoom map in
let wrapped_latlng = Map.wrap_latlng latlng map in
let is_wrapped = not @@ Latlng.equals latlng wrapped_latlng in
if is_wrapped then (
(* wrap coordinates so we don't drift into a parralel universe
and lose track of markers *)
let w_lat = Latlng.lat wrapped_latlng in
let w_lng = Latlng.lng wrapped_latlng in
set_view (w_lat, w_lng, zoom);
(w_lat, w_lng, zoom) )
else (lat, lng, zoom)
(* todo better leaflet interface for open/close_popup? *)
let open_popup content latlng =
let popup = Popup.create ~content:(Some content) ~latlng:(Some latlng) [||] in
Map.open_popup popup map
let close_popup () = Map.close_popup None map
let on_move_end f = Map.on Event.Move_end f map
let on_zoom_end f = Map.on Event.Zoom_end f map
let on_click f = Map.on Event.Click f map
(* init map, setup events *)
let () =
Note_brr.Elr.on_add
(fun () ->
Fmt.pr "leaflet map init@.";
let osm_layer = Layer.create_tile_osm [||] in
Layer.add_to map osm_layer;
set_view (Storage.init_map_view ()) )
map_el;
on_move_end (fun _ev ->
let o = get_view () in
Events.send_action (Map_input (Move_end o)) );
on_zoom_end (fun _ev ->
let o = get_view () in
Events.send_action (Map_input (Zoom_end o)) );
on_click (fun ev ->
let latlng =
(* TODO wrap/check it server side too *)
(* wrap it to avoid creating thread on wrong earth *)
let latlng = Event.latlng ev in
Map.wrap_latlng latlng map
in
let lat = Latlng.lat latlng in
let lng = Latlng.lng latlng in
Events.send_action (Map_input (Click_latlng (lat, lng))) );
(* TODO:
- show a loading animation until we get the geolocation
- show something in case of error
- add special marker on map *)
let geolocalize _ev =
let open Brr_io.Geolocation in
let l = of_navigator G.navigator in
let opts = opts ~high_accuracy:true () in
Events.send_action (Map_input Geoloc_start);
(* only get first Geoloc_pos for now
let _ : watch_id =
watch l ~opts (fun pos_res ->
*)
let _fut : unit Fut.t =
get l ~opts
|> Fut.map (fun pos_res ->
match pos_res with
| Error err -> Events.send_action (Map_input (Geoloc_err err))
| Ok pos ->
Events.send_action (Map_input (Geoloc_pos pos));
let lat = Pos.latitude pos in
let lng = Pos.longitude pos in
let zoom = 17 in
set_view (lat, lng, zoom);
Storage.set_map_view (lat, lng, zoom);
() )
in
()
in
hold_on geoloc_btn Ev.click geolocalize;
()
let toggle_latlng_popup latlng_opt =
match latlng_opt with
| None -> close_popup ()
| Some (lat, lng) ->
(* TODO add a marker with special icon here *)
open_popup "create thread here" (Latlng.create ~lat ~lng)
module Markers = struct
let icon mode =
(* TODO define in App *)
let default_url = "/assets/img/marker-icon.png" in
let default_icon = Icon.create default_url [||] in
let selected_icon = Icon.create default_url [||] in
match mode with `Selected -> selected_icon | `Normal -> default_icon
let selected_id = ref None
let select id_opt = selected_id := id_opt
let is_selected id =
match !selected_id with
| None -> false
| Some selected_id -> Int.equal selected_id id
let refresh =
let set_layer =
(* replace previous geojson layer: avoid stacking layers and handle thread deletion *)
let layer_ref = ref None in
fun layer ->
Option.iter (Layer.remove_from map) !layer_ref;
layer_ref := Some layer;
Layer.add_to map layer
in
let on_marker_click id =
Events.send_action (Map_input (Click_marker id));
Navigation.load (Thread (Loading id))
in
let spawn_marker geojsonpoint_feature latlng =
let id =
let feature_properties = Jv.get geojsonpoint_feature "properties" in
Jv.get feature_properties "id" |> Jv.to_int
in
let icon =
match is_selected id with
| false -> icon `Normal
| true -> icon `Selected
in
let marker = Marker.create latlng [| Icon icon |] in
Layer.on Event.Click (fun _ev -> on_marker_click id) marker;
marker
in
fun catalog ->
let geojson_res =
let open Types in
catalog
|> List.map (fun v -> (v.lat, v.lng, v.op.id))
|> Json_data.Write.geojson_markers |> Jstr.of_string |> Brr.Json.decode
in
match geojson_res with
| Error e ->
Fmt.failwith "Markers.refresh failure: geojson serialization error `%s`"
(Util.str_of_error e)
| Ok geojson ->
let layer =
Layer.create_geojson geojson [| Point_to_layer spawn_marker |]
in
set_layer layer
end
let f t_s =
let open Model in
S.map (fun t -> t.post_form.latlng) t_s
|> S.changes
|> hold_endless toggle_latlng_popup;
S.map (fun t -> t.page) t_s
|> S.changes
|> E.map (fun _ -> None)
|> hold_endless toggle_latlng_popup;
(* todo: refresh on selection change may be too much because we clear and re-add all markers *)
S.map (fun t -> t.catalog) t_s |> S.changes |> hold_endless Markers.refresh;
S.map get_thread_w_reply t_s
|> S.map (Option.map Page.unwrap_thread_id)
|> S.changes
|> hold_endless (fun id_opt ->
Markers.select id_opt;
Markers.refresh (S.value t_s).catalog );
let el = El.div ~at:[ class' "home-right" ] [ map_el; buttons ] in
el

24
src/client/main.ml Normal file
View file

@ -0,0 +1,24 @@
open Brr
open Note
open Model
let ui : t -> t signal * El.t list =
fun t ->
let def t_s =
let els = Html.f t_s in
let do_stuff =
let do_actions = E.map do_action Events.actions in
let do_data_updates = E.map do_data_update Events.data_updates in
let do_error_popup_updates = E.map do_error Events.errors in
E.select [ do_actions; do_data_updates; do_error_popup_updates ]
in
let t_s' = S.accum (S.value t_s) do_stuff in
(t_s', (t_s', els))
in
S.fix t def
let () =
let t_s, els = ui (init ()) in
(* don't forget to hold model signal! *)
Logr.(hold @@ S.log t_s (fun (_ : Model.t) -> ()));
El.set_children Util.body els

303
src/client/model.ml Normal file
View file

@ -0,0 +1,303 @@
open Types
open Client_types
open Page
type t =
{ (* navigation state *)
session : session
; fragment : Fragment.t
; (* ui state *)
catalog : thread list
; page : Page.t
; post_form : Post_form_data.t
; map_view : float * float * int
; (* todo: just remove rect from here *)
quickview : (rect * (post_id, post) wrap) option
; opened_image : post_id option
; error : Client_types.error option
}
(* TODO better session initialization/no dummy *)
(* initialize session with dummy data and launch a GET.session request
catalog will be fetched on home and thread page navigation *)
let init () =
Fmt.pr "model init@.";
let dummy_session =
{ user_private = None; csrf_token = "dummy"; csrf_time_limit = 0.0 }
in
Network.GET.session ();
{ session = dummy_session
; fragment = Empty
; catalog = Db.get_catalog ()
; page = Home
; post_form = Post_form_data.empty
; map_view = Storage.init_map_view ()
; quickview = None
; opened_image = None
; error = None
}
(* TODO mv to ../util.ml *)
let user_private_to_public u =
let User_private.
{ user_id; user_nick; user_is_admin; bio; avatar_info; email = _ } =
u
in
{ user_id; user_nick; user_is_admin; bio; avatar_info }
let get_user t = Option.map user_private_to_public t.session.user_private
let get_user_private t = t.session.user_private
let get_user_admin t =
match get_user t with
| None -> None
| Some u -> ( match u.user_is_admin with false -> None | true -> Some u )
let get_thread_w_reply t = match t.page with Thread t -> Some t | _ -> None
(* TODO
- use CSS `scroll-margin-top` property *)
(* History.push_state does not fire hashchange + scroll, so we have to do it
manually this relies on html `id` attribute:
- id attribute must exists and be unique
be careful to have posts only once in the html
- if html is invalid and multiple element have the same id.
it scroll to the first, which can be in a hidden page
This must be called after a fragment change when page is ready.
The DOM need to be re-rendered before we scroll_into_view.
So the scoll is delayed to the next JavaScript event loop cycle
with [Futr.to_event] *)
let schedule_scroll_into_view =
let f opt =
match opt with
| None -> ()
| Some "" -> ()
| Some id -> (
match Util.find_html_el_by_id id with
| None ->
Fmt.failwith "scroll_into_view: html element with id `%s` not found@."
id
| Some el ->
Fmt.pr "scroll_into_view `%s`@." id;
Brr.El.scroll_into_view el )
in
fun s ->
let open Note in
(* TODO hold; need a hold_once? *)
let _ : Logr.t =
Fut.return s |> Note_brr.Futr.to_event |> E.obs
|> Logr.(app (const f))
|> Logr.create ~now:false
in
()
let load_aux find is_404 id =
match find id with
| Some v -> Ready v
| None -> (
match is_404 id with true -> Not_found id | false -> Loading id )
let load_thread v =
let id = unwrap_thread_id v in
load_aux Db.find_thread_w_reply Db.thread_is_404 id
let load_post v =
let id = unwrap_post_id v in
load_aux Db.find_post Db.post_is_404 id
let load_user v =
let id = unwrap_user_id v in
load_aux Db.find_user Db.user_is_404 id
let load_fragment page fragment =
let open Fragment in
match fragment with
| Empty | Top | Bottom -> fragment
| Id v ->
let id = unwrap_id v in
(* only consider fragment on thread pages *)
let v =
match page with
| Thread v -> (
match v with
| Loading _ -> Loading id
| Not_found _ -> Not_found id
| Ready v -> (
match List.exists (fun p -> p.id = id) v.reply_l with
| false -> Not_found id
| true -> Ready id ) )
| _ -> Not_found id
in
Id v
let load_page = function
| Home -> Home
| New_thread -> New_thread
| About -> About
| Register -> Register
| Login -> Login
| Profile -> Profile
| Account -> Account
| Admin _ -> Admin (Ready (Db.get_reports ()))
| Thread id -> Thread (load_thread id)
| User id -> User (load_user id)
| Delete id -> Delete (load_post id)
| Report id -> Report (load_post id)
let load_quickview opt =
opt
|> Option.map (fun (rect, v) ->
( rect
, match v with Ready _ | Not_found _ -> v | Loading _ -> load_post v ) )
let load_model t =
let session = Db.get_session () in
let catalog = Db.get_catalog () in
let page = load_page t.page in
let fragment = load_fragment page t.fragment in
let () =
match
(Fragment.get_ready_value t.fragment, Fragment.get_ready_value fragment)
with
| _, None | Some _, Some _ -> ()
| None, Some s -> schedule_scroll_into_view s
in
let quickview = load_quickview t.quickview in
{ t with session; catalog; page; fragment; quickview }
let do_post_form_action form_action post_form =
let open Post_form_data in
match form_action with
| Form_open -> { post_form with is_open = true }
| Form_close -> { post_form with is_open = false }
| Form_insert_quote id ->
let comment =
let s = post_form.comment in
(* insert quote on newline *)
match String.ends_with ~suffix:"\n" s || String.length s = 0 with
| true -> Fmt.str "%s>>%d " s id
| false -> Fmt.str "%s@\n>>%d " s id
in
{ post_form with comment }
| Form_comment comment -> { post_form with comment }
| Form_file file -> { post_form with file }
| Form_alt alt -> { post_form with alt }
| Form_subject subject -> { post_form with subject }
| Form_latlng latlng -> { post_form with latlng }
| Form_reset -> Post_form_data.empty
let do_map_action a t =
let set_latlng t opt =
let post_form = do_post_form_action (Form_latlng opt) t.post_form in
{ t with post_form }
in
match a with
| Move_end map_view | Zoom_end map_view ->
Storage.set_map_view map_view;
{ t with map_view }
| Geoloc_start -> t
| Geoloc_pos (_pos : Brr_io.Geolocation.Pos.t) -> t
| Geoloc_err (_err : Brr_io.Geolocation.Error.t) -> t
| Click_latlng latlng -> (
match t.page with New_thread -> set_latlng t (Some latlng) | _ -> t )
| Click_marker _thread_id -> set_latlng t None
let do_action : Client_types.action -> t -> t =
fun action t ->
Fmt.pr {|do action: "%a"@.|} pp_action action;
match action with
| Navigation_event (page_opt, frag) ->
let page =
match page_opt with
| None -> t.page
| Some loading_page ->
Network.GET.f loading_page;
load_page loading_page
in
let fragment = load_fragment page frag in
let () =
match Fragment.get_ready_value fragment with
| None -> ()
| Some s -> schedule_scroll_into_view s
in
(* when we click the id to go to post blur event is not triggered,
so we clear quick view on hashchange too *)
let quickview = None in
let post_form =
match page_opt with
| None -> t.post_form
| Some _ -> { t.post_form with is_open = false; latlng = None }
in
{ t with page; fragment; quickview; post_form }
| Post_form_change form_action -> (
(* TODO error message/feedback; use Validate_str *)
(* ignore reply form action if not logged in *)
match get_user t with
| None -> t
| Some _ ->
let post_form = do_post_form_action form_action t.post_form in
{ t with post_form } )
| Map_input map_action -> do_map_action map_action t
| Submit_event (Form_kind.W kind, form) ->
let session = Db.get_session () in
Network.POST.f kind form session.csrf_token;
let t =
match kind with
| Logout ->
(* todo reload window? Brr.Window.reload Brr.G.window; *)
(* clear all state on logout
we do it here so we can logout even if offline *)
Db.clear ();
Storage.clear ();
init ()
| _ -> t
in
t
| Quickview_change opt ->
let quickview =
opt |> Option.map (fun (rect, v) -> (rect, Loading v)) |> load_quickview
in
begin
match quickview with
| Some (_, Loading post_id) -> Network.GET.post post_id
| _ -> ()
end;
{ t with quickview }
| Image_click id -> (
match t.opened_image with
| Some current_image_id when Int.equal current_image_id id ->
{ t with opened_image = None }
| Some _ | None -> { t with opened_image = Some id } )
| Clear_error -> { t with error = None }
let do_data_update : Client_types.data_update -> t -> t =
fun action t ->
Fmt.pr {|do data update: "%a"@.|} pp_data_update action;
begin
match action with
| Post_update v -> Db.add_post v
| Thread_update thread_w_reply ->
Db.update_thread_w_reply (Some thread_w_reply)
| Catalog_update l -> Db.update_catalog l
| User_update u -> Db.update_user (Some u)
| Reports_update reports -> Db.update_reports reports
| Session_update session -> Db.update_session session
end;
load_model t
let do_error : Client_types.error -> t -> t =
fun e t ->
Fmt.pr {|do error: "%a"@.|} pp_error e;
let t = { t with error = Some e } in
let () =
match e with
| Network_err _ -> ()
| Err_response e -> (
match e with
| Not_found_post id -> Db.add_post_404 id
| Not_found_thread id -> Db.add_thread_404 id
| Not_found_user user_id -> Db.add_user_404 user_id
| _ -> () )
in
load_model t

98
src/client/navigation.ml Normal file
View file

@ -0,0 +1,98 @@
(* TODO
- use navigation API when ready(?)
https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API *)
open Brr
open Util
let of_uri uri =
let page, frag = Page.of_uri uri in
let frag =
let open Client_types.Fragment in
match of_string frag with
| Error e ->
Fmt.epr "%s@." e;
Empty
| Ok v -> v
in
(page, frag)
let go_to ~just_change_hash uri =
let open Window in
let history = history window in
History.push_state ~uri history;
let page, frag = of_uri uri in
let opt = match just_change_hash with true -> None | false -> Some page in
Events.send_action (Navigation_event (opt, frag));
()
let load p =
let uri = Page.to_uri p in
go_to ~just_change_hash:false uri;
()
let update_to_current_location () =
let uri = Window.location window in
let page, frag = of_uri uri in
Events.send_action (Navigation_event (Some page, frag));
()
let on_load () =
(* todo hold
only observe once; destroy logger after first event *)
hold_endless_on_window Ev.load (fun _ev -> update_to_current_location ());
()
let on_link_click () =
let handle_link href =
let open Window in
let is_local = String.starts_with ~prefix:"/" href in
let is_hash = String.starts_with ~prefix:"#" href in
if is_local || is_hash then
(* how to build uri correctly from just href..? *)
let href_jstr = Jstr.v href in
let uri =
let with_uri =
match is_local with
| true ->
let e = Jstr.v "" in
fun uri -> Uri.with_uri ~path:e ~query:e ~fragment:e uri
| false -> fun uri -> Uri.with_uri ~fragment:href_jstr uri
in
let base =
match with_uri (location window) with
| Error e ->
Fmt.failwith "on_link_click: with_uri error `%s`"
(Util.str_of_error e)
| Ok v -> Uri.to_jstr v
in
Uri.v ~base href_jstr
in
go_to ~just_change_hash:is_hash uri
in
let navigation_handler ev =
(* TODO rm magick if possible *)
let el : El.t = Obj.magic (Ev.target ev) in
begin
if Jstr.equal (El.tag_name el) (Jstr.v "a") then
match El.at (Jstr.v "href") el with
| None -> Fmt.failwith "<a> element with no href"
| Some href -> begin
Ev.prevent_default ev;
handle_link (Jstr.to_string href)
end
end
in
hold_on body Ev.click navigation_handler
let on_pop_state () =
let open Window in
hold_endless_on_window History.Ev.popstate (fun _ev ->
update_to_current_location () );
()
(* setup navigation listeners *)
let () =
on_load ();
on_link_click ();
on_pop_state ();
()

214
src/client/network.ml Normal file
View file

@ -0,0 +1,214 @@
open Types
open Client_types
open Util
(* TODO handle no network connection/unreachable server *)
let handle_response meth fetch read_ok on_ok =
let open Brr_io.Fetch in
let read_body response res =
match res with
| Error e -> Error (Body_err (str_of_error e))
| Ok jstr -> (
let url = Jstr.to_string (Response.url response) in
let status = Response.status response in
let status_text = Jstr.to_string (Response.status_text response) in
let body = Jstr.to_string jstr in
let r = { meth; url; status; status_text; body } in
match Response.ok response with
| true -> (
match read_ok r.body with
| Error e -> Error (Read_err (e, r))
| Ok v -> Ok (Either.Left v) )
| false -> (
match Json_data.Read.err r.body with
| Error e -> Error (Read_err (e, r))
| Ok v -> Ok (Either.Right v) ) )
in
let read_response res =
match res with
| Error e -> Fut.return @@ Error (Fetch_err (str_of_error e))
| Ok response ->
let body = Response.as_body response in
Body.text body |> Fut.map (read_body response)
in
let f res =
read_response res
|> Fut.map (function
| Error e ->
Events.send_error (Network_err e);
()
| Ok (Either.Left v) ->
on_ok v;
()
| Ok (Either.Right err) ->
Events.send_error (Err_response err);
() )
in
Fut.bind (fetch ()) f
module GET = struct
type _ t =
| Catalog : thread list t
| Thread : int -> Thread_w_reply.t t
| Post : int -> post t
| Admin : report list t
| User : string -> user t
| Session : session t
let reader : type a. a t -> string -> (a, string) result =
fun t ->
let open Json_data.Read in
match t with
| Catalog -> catalog
| Thread _id -> thread_w_reply
| Post _id -> post
| Admin -> reports
| User _id -> user
| Session -> session
let url : type a. a t -> string =
fun t ->
Fmt.str "/api%s"
( match t with
| Catalog -> "/catalog"
| Thread id -> Fmt.str "/thread/%d" id
| Post id -> Fmt.str "/post/%d" id
| Admin -> "/admin"
| User id -> Fmt.str "/user/%s" id
| Session -> "/session" )
let on_ok : type a. a t -> a -> unit =
fun req v ->
let open Client_types in
let open Events in
begin
match req with
| Catalog -> send_data_update (Catalog_update v)
| Thread _id -> send_data_update (Thread_update v)
| Post _id -> send_data_update (Post_update v)
| Admin -> send_data_update (Reports_update v)
| User _id -> send_data_update (User_update v)
| Session -> send_data_update (Session_update v)
end;
()
let fetch t =
let s = url t in
Fmt.pr "fetch `%s`@." s;
let fetch () = Brr_io.Fetch.url (Jstr.v s) in
let _fut = handle_response GET fetch (reader t) (on_ok t) in
()
let catalog () = fetch Catalog
let thread id = fetch (Thread id)
let post id = fetch (Post id)
let admin () = fetch Admin
let user id = fetch (User id)
let session () = fetch Session
let f page =
let open Page in
match page with
| About | Register | Login -> ()
| Account | Profile -> session ()
| Home | New_thread -> catalog ()
| Admin _ -> admin ()
| Thread v ->
let id = unwrap_thread_id v in
thread id;
catalog ()
| Delete v | Report v ->
let id = unwrap_post_id v in
post id
| User v ->
let id = unwrap_user_id v in
user id
end
module POST = struct
open Form_kind
let reader : type a. a t -> string -> (a, string) result =
fun t ->
let open Json_data.Read in
match t with
| Home -> thread_w_reply
| Register -> session
| Login -> session
| Logout -> session
| Profile -> session
| Account -> session
| Thread _ -> thread_w_reply
| Delete _ -> post
| Report _ -> reports
| Admin_ignore _ -> reports
| Admin_delete _ -> post
| Admin_banish _ -> user
(* TODO implement redirection mechanism *)
let on_ok : type a. a t -> a -> unit =
fun o v ->
let open Client_types in
let open Events in
begin
match o with
| Home ->
send_data_update (Thread_update v);
send_action (Post_form_change Form_reset);
let id = v.op.id in
Navigation.load (Thread (Loading id))
| Thread _ ->
(* server respond to successful POST with full thread *)
send_data_update (Thread_update v);
send_action (Post_form_change Form_reset);
let id = v.op.id in
Navigation.load (Thread (Loading id))
| Register ->
send_data_update (Session_update v);
Navigation.load Profile
| Login ->
send_data_update (Session_update v);
Navigation.load Home
| Logout -> send_data_update (Session_update v)
| Delete _ -> (
let is_op = Int.equal v.id v.parent_t_id in
match is_op with
| true -> Navigation.load Home
| false -> Navigation.load (Thread (Loading v.parent_t_id)) )
| Report _ ->
send_data_update (Reports_update v);
(* TODO need redirection to page before report here *)
Navigation.load Home
| Admin_ignore _ -> send_data_update (Reports_update v)
| Admin_delete _ -> ()
| Admin_banish _ -> ()
| Profile -> send_data_update (Session_update v)
| Account -> send_data_update (Session_update v)
end;
()
let fetch t request =
let fetch () = Brr_io.Fetch.request request in
handle_response POST fetch (reader t) (on_ok t)
let f kind form_el csrf_token =
let open Brr_io in
let method' = Jstr.v "POST" in
let form = Form.of_el form_el in
let action = Form_kind.action kind |> Jstr.v in
let form_data = Form.Data.of_form form in
Form.Data.set form_data (Jstr.v "dream.csrf") (Jstr.v csrf_token);
let body = Fetch.Body.of_form_data form_data in
let init = Fetch.Request.init ~method' ~body () in
let request = Fetch.Request.v ~init action in
let fut = fetch kind request in
let _fut : unit Fut.t =
Fut.map (fun () -> Fmt.pr "`%s` xhr done@." (Form_kind.name kind)) fut
in
()
end

193
src/client/page.ml Normal file
View file

@ -0,0 +1,193 @@
open Types
module Kind = struct
type t =
| Home
| New_thread
| Thread
| About
| Register
| Login
| Admin
| Profile
| Account
| User
| Delete
| Report
let equal : t -> t -> bool = fun a b -> Obj.magic a = Obj.magic b
let to_string = function
| Home -> "home"
| New_thread -> "new-thread"
| Thread -> "thread"
| About -> "about"
| Register -> "register"
| Login -> "login"
| Admin -> "administration"
| Profile -> "profile"
| Account -> "account"
| User -> "user"
| Delete -> "delete"
| Report -> "report"
let of_string s =
match s with
| "" | "home" -> Some Home
| "new-thread" -> Some New_thread
| "thread" -> Some Thread
| "about" -> Some About
| "register" -> Some Register
| "login" -> Some Login
| "administration" -> Some Admin
| "profile" -> Some Profile
| "account" -> Some Account
| "user" -> Some User
| "delete" -> Some Delete
| "report" -> Some Report
| _ -> None
let to_emoji = function
| Home -> Some "🗺️"
| About -> Some "🛸"
| Register -> Some "🍎"
| Login -> Some "🚪"
| Admin -> Some "🪄"
| Profile -> Some "🦩"
| Account -> Some ""
| New_thread | Thread | User | Delete | Report -> None
end
type ('a, 'b) wrap =
| Loading of 'a
| Not_found of 'a
| Ready of 'b
type t =
| Home
| New_thread
| Thread of (int, Thread_w_reply.t) wrap
| About
| Register
| Login
| Admin of (unit, report list) wrap
| Profile
| Account
| User of (user_id, user) wrap
| Delete of (post_id, post) wrap
| Report of (post_id, post) wrap
let is_ready = function
| Home | New_thread | About | Register | Login | Profile | Account -> true
| Thread (Ready _)
| Admin (Ready _)
| User (Ready _)
| Delete (Ready _)
| Report (Ready _) ->
true
| _ -> false
let unwrap_thread_id = function
| Loading v | Not_found v -> v
| Ready v -> v.Thread_w_reply.op.id
let unwrap_post_id = function Loading v | Not_found v -> v | Ready v -> v.id
let unwrap_user_id = function
| Loading v | Not_found v -> v
| Ready v -> v.user_id
let to_kind = function
| Home -> Kind.Home
| New_thread -> New_thread
| Thread _ -> Thread
| About -> About
| Register -> Register
| Login -> Login
| Admin _ -> Admin
| Profile -> Profile
| Account -> Account
| User _ -> User
| Delete _ -> Delete
| Report _ -> Report
(* TODO handle failure *)
let of_uri =
let admin () = Admin (Loading ()) in
let user id = User (Loading id) in
let thread id = Thread (Loading id) in
let delete id = Delete (Loading id) in
let report id = Report (Loading id) in
let bind_int opt f = Option.bind opt int_of_string_opt |> Option.map f in
let of_kind ~item_id k =
match k with
| Kind.Home -> Some Home
| New_thread -> Some New_thread
| About -> Some About
| Register -> Some Register
| Login -> Some Login
| Profile -> Some Profile
| Account -> Some Account
| Admin -> Some (admin ())
| User -> item_id |> Option.map user
| Thread -> bind_int item_id thread
| Delete -> bind_int item_id delete
| Report -> bind_int item_id report
in
fun uri ->
let open Brr in
let segment_1, segment_2 =
let segments =
match Uri.path_segments uri with
| Error e ->
Fmt.failwith "Page.of_uri failure: path_segments error `%s`"
(Util.str_of_error e)
| Ok l -> List.map Jstr.to_string l
in
match segments with
| [] -> ("home", None)
| x :: [] -> (x, None)
| [ x; y ] -> (x, Some y)
| _ -> Fmt.failwith "Page.of_uri failure: invalid path segments"
in
match
Option.bind (Kind.of_string segment_1) (of_kind ~item_id:segment_2)
with
| None -> Fmt.failwith "Page.of_uri failure: invalid path format"
| Some page ->
let fragment_opt = uri |> Uri.fragment |> Jstr.to_string in
(page, fragment_opt)
let to_path o =
let page_name = to_kind o |> Kind.to_string in
let param =
match o with
| Home | New_thread | About | Register | Login | Profile | Account -> None
| Admin _ -> None
| User v ->
let id = unwrap_user_id v in
Some id
| Thread v ->
let id = unwrap_thread_id v in
Some (string_of_int id)
| Delete v | Report v ->
let id = unwrap_post_id v in
Some (string_of_int id)
in
match param with
| None -> Fmt.str "/%s" page_name
| Some s -> Fmt.str "/%s/%s" page_name s
let to_uri o =
let open Brr in
let uri =
(* clear query and fragment of the current uri *)
let empty_params = Uri.Params.of_jstr (Jstr.v "") in
let uri = Window.location G.window in
let uri = Uri.with_query_params uri empty_params in
Uri.with_fragment_params uri empty_params
in
let path = Jstr.v (to_path o) in
match Uri.with_uri ~path uri with
| Error e -> Fmt.failwith "%s" (Jv.of_error e |> Jv.to_string)
| Ok uri -> uri

43
src/client/storage.ml Normal file
View file

@ -0,0 +1,43 @@
module Local = struct
open Brr
open Brr_io
let local = Storage.local G.window
let set k v =
match Storage.set_item local (Jstr.v k) (Jstr.v v) with
| (exception Jv.Error e) | Error e ->
Fmt.failwith "local storage failure `%s`" (Util.str_of_error e)
| Ok () -> ()
let get k = Storage.get_item local (Jstr.v k) |> Option.map Jstr.to_string
let clear () = Storage.clear local
end
let init_map_view () =
let default_map_view = (51.505, -0.09, 13) in
let lat = Local.get "lat" in
let lng = Local.get "lng" in
let zoom = Local.get "zoom" in
match (lat, lng, zoom) with
| Some lat, Some lng, Some zoom ->
let lat = lat |> Jstr.v |> Jstr.to_float in
let lng = lng |> Jstr.v |> Jstr.to_float in
let zoom =
match int_of_string_opt zoom with
| None -> Fmt.failwith "init_map_view: int_of_string failure on zoom"
| Some zoom -> zoom
in
(lat, lng, zoom)
| _ -> default_map_view
let set_map_view (lat, lng, zoom) =
Local.set "lat" (string_of_float lat);
Local.set "lng" (string_of_float lng);
Local.set "zoom" (string_of_int zoom);
()
let clear () =
Local.clear ();
()

89
src/client/util.ml Normal file
View file

@ -0,0 +1,89 @@
open Brr
let str = Jstr.v
let str_of_error e = Jv.of_error e |> Jv.to_string
(* redefine At module? *)
let class' j = At.class' (str j)
let id j = At.id (str j)
let href j = At.href (str j)
let src j = At.src (str j)
let alt j = At.v (str "alt") (str j)
let title j = At.title (str j)
let type' j = At.type' (str j)
let name j = At.name (str j)
let value j = At.value (str j)
let mk_at k v = At.v (str k) (str v)
let el_txt s = El.txt (str s)
let h1 s = El.h1 [ el_txt s ]
let h2 s = El.h2 [ el_txt s ]
let window = G.window
let window_as_target = Window.as_target window
let window_jv = Jv.get Jv.global "window"
let window_width () = Jv.get window_jv "innerWidth" |> Jv.to_float
let window_height () = Jv.get window_jv "innerHeight" |> Jv.to_float
let document = G.document
let document_as_target = Document.as_target document
let body = Document.body document
let find_html_el_by_id id =
Document.find_el_by_id G.document (Jstr.of_string id)
let get_bounds el =
let x = El.bound_x el in
let y = El.bound_y el in
let w = El.bound_w el in
let h = El.bound_h el in
(x, y, w, h)
let clamp ~min ~max x = Float.max (Float.min max x) min
(* -- Note util -- *)
open Note
open Note_brr
let def_off b_s el = Elr.def_class (str "off") b_s el
let def_on b_s el = Elr.def_class (str "off") (S.map not b_s) el
let def_disabled b_s el =
Elr.def_at At.Name.disabled
(S.map (function true -> Some (Jstr.v "") | false -> None) b_s)
el
let hold_on el ev_type f =
let event = Evr.on_el ev_type Fun.id el in
Elr.may_hold_logr el (E.log event f);
()
let hold_event_on el event f =
Elr.may_hold_logr el (E.log event f);
()
let hold_endless f e = Logr.may_hold (E.log e f)
let hold_endless_on_window ev_type f =
let event = Evr.on_target ev_type Fun.id window_as_target in
hold_endless f event;
()

9
src/comment/ast.ml Normal file
View file

@ -0,0 +1,9 @@
type item =
| Id of int
| Txt of string
type line =
| Line of item list
| Line_quote of item list
type t = line list

85
src/comment/comment.ml Normal file
View file

@ -0,0 +1,85 @@
include Ast
let backlinks comment =
comment
|> List.map (function Line l -> l | Line_quote l -> l)
|> List.flatten
|> List.filter_map (function Id id -> Some id | Txt _s -> None)
|> List.sort_uniq Int.compare
let of_string =
(* merge adjacent Txt together *)
let merge_txt_aux item_l =
let rec loop l acc txt_acc =
match l with
| [] -> (
match txt_acc with
| [] -> List.rev acc
| _ ->
let txt = Txt (String.concat "" (List.rev txt_acc)) in
List.rev (txt :: acc) )
| Txt s :: tl -> loop tl acc (s :: txt_acc)
| hd :: tl -> (
match txt_acc with
| [] -> loop tl (hd :: acc) []
| _ ->
let txt = Txt (String.concat "" (List.rev txt_acc)) in
loop tl (hd :: txt :: acc) [] )
in
let l = loop item_l [] [] in
l
in
let merge_txt =
fun line_l ->
List.map
(function
| Line l -> Line (merge_txt_aux l)
| Line_quote l -> Line_quote (merge_txt_aux l) )
line_l
in
(* for debug and error msg *)
let token_to_string = function
| Parser.EOF -> "eof"
| NEWLINE -> "newline"
| GT -> ">"
| ID i -> Fmt.str "id: `%d`" i
| TXT s -> Fmt.str "text: `%s`" s
in
(* parser *)
let from_lexbuf =
let parser =
MenhirLib.Convert.Simplified.traditional2revised Parser.comment
in
fun buf ->
let provider () =
let tok = Lexer.token buf in
let start, stop = Sedlexing.lexing_positions buf in
(tok, start, stop)
in
try Ok (parser provider) with
| Lexer.Lexing_error e -> Error e
| Sedlexing.MalFormed -> Error (Fmt.str "malformed utf8 encoding")
| Parser.Error ->
let tok = Lexer.token buf |> token_to_string in
Error (Fmt.str "unexpected token `%s`" tok)
in
fun s -> from_lexbuf (Sedlexing.Utf8.from_string s) |> Result.map merge_txt
let to_string =
let pp_item fmt = function
| Txt s -> Fmt.pf fmt "%s" s
| Id i -> Fmt.pf fmt ">>%d" i
in
let pp_line =
let sep = Fmt.any "" in
fun fmt line ->
match line with
| Line items -> Fmt.pf fmt "%a" (Fmt.list ~sep pp_item) items
| Line_quote items -> Fmt.pf fmt ">%a" (Fmt.list ~sep pp_item) items
in
let pp fmt line_l =
Fmt.pf fmt "%a" (Fmt.list ~sep:(Fmt.any "@\n") pp_line) line_l
in
fun comment ->
let s = Fmt.str "%a" pp comment in
s

9
src/comment/dune Normal file
View file

@ -0,0 +1,9 @@
(menhir
(modules parser))
(library
(name comment)
(modules comment lexer parser ast)
(libraries menhirLib sedlex fmt)
(preprocess
(pps sedlex.ppx)))

30
src/comment/lexer.ml Normal file
View file

@ -0,0 +1,30 @@
open Sedlexing
open Parser
exception Lexing_error of string
let newline = [%sedlex.regexp? '\n' | "\r\n"]
let gt = [%sedlex.regexp? '>']
let id = [%sedlex.regexp? ">>", Plus '0' .. '9']
let txt = [%sedlex.regexp? Plus (Compl ('>' | '\n'))]
let token lexbuf =
match%sedlex lexbuf with
| eof -> EOF
| newline -> NEWLINE
| id ->
let lexeme = Utf8.lexeme lexbuf in
let id =
ID (int_of_string (String.sub lexeme 2 (String.length lexeme - 2)))
in
id
| gt -> GT
| txt ->
let lexeme = Utf8.lexeme lexbuf in
TXT lexeme
| _ -> raise (Lexing_error ("Unexpected character: " ^ Utf8.lexeme lexbuf))
let lexer lexbuf = Sedlexing.with_tokenizer token lexbuf

30
src/comment/parser.mly Normal file
View file

@ -0,0 +1,30 @@
%{
open Ast
%}
%token EOF
%token NEWLINE
%token <int> ID
%token <string> TXT
%token GT
%start comment
%type <t> comment
%%
comment:
| separated_nonempty_list(NEWLINE, line) EOF { $1 }
line:
| {Line ([]) }
| ID items {Line ( (Id $1) :: $2) }
| TXT items {Line ( (Txt $1) :: $2) }
| GT items {Line_quote $2 }
items:
| list(item) { $1 }
item:
| ID { Id $1 }
| TXT { Txt $1 }
| GT { Txt ">" }

View file

@ -1,23 +0,0 @@
# What is Permap
Permap is an open source geo-message-board software written in OCaml.
Permap was initially made to be a gardening/permaculture forum.
Permap's aim is to help people find friends with similar interests around them
and build local communities.
You can make threads with geographical coordinate,
this way you can find people near you doing interesting stuffs,
socialize with them and share local knowledge.
## Permap's future
- Make permap federate
- More than coordinates
Make threads on anything with a geographical position.
Instead of making threads with a simple (latitude * longitude) data,
we want to be able to make threads on any OpenStreetMap's item/ActivityPub object
that can resolve to a geographical position.

File diff suppressed because one or more lines are too long

View file

@ -1,169 +0,0 @@
body {
padding-top: 0rem;
padding-bottom: 3rem;
color: #5a5a5a;
background-color: #e8eaf6;
line-height: 1.6;
font-size: 18px;
}
.featurette-divider {
margin: 5rem 0;
}
#page-title {
text-align: center;
}
blockquote.blockquote {
border-left: 6px solid #3131e0;
border-radius: 6px;
padding-left: 16px;
background-color: #c0c0f0;
}
#map {
height: 800px;
width: auto;
}
.post {
background-color: #C5E1A5;
margin: 5px 5px 5px 5px;
border: 2px solid #FFB300;
padding: 2px;
display: table;
}
.post + .highlight {
background-color: #9dd162;
margin: 5px 5px 5px 5px;
border: 2px solid #FFB300;
padding: 2px;
display: table;
}
.post + .selected {
background-color: #9dd162;
margin: 5px 5px 5px 5px;
border: 2px solid #FFB300;
padding: 2px;
display: table;
}
.post-info {
display: block;
width: 100%;
}
.nick {
color: #FFB300;
}
.post-comment {
display: block;
padding-top: 10px;
color: #333333;
}
.post-image-container {
float: left;
padding: 5px 5px 5px 5px;
}
.post-image {
max-width: 300px;
max-height: 300px;
}
.post-image-big {
max-width: 1200px;
height: auto;
}
.quote {
color: green;
}
.quote-link {
background-color: #FCE4EC;
padding: 2px;
text-align: center;
color: #5a5a5a;
font-size: 10px;
border-radius: 12px;
border: 2px solid DodgerBlue;
}
.quote-link:focus {
background-color: #FCE4EC;
padding: 2px;
text-align: center;
color: #5a5a5a;
font-size: 10px;
border-radius: 12px;
border: 2px solid DodgerBlue;
}
.post-form {
background-color: #FCE4EC;
margin: 5px 5px 5px 5px;
border: 2px solid #FFB300;
padding: 2px;
display: table;
width: 500px;
}
#newthread-form {
visibility: hidden;
}
a.preview-link {
text-decoration: none;
color: unset;
}
.thread-subject {
margin: auto;
width: 50%;
text-align: center;
color: #5a5a5a;
font-size: 30px;
}
.tag {
background-color: #FFB300;
border-radius: 4px;
padding: 2px;
}
.category {
background-color: #FFB300;
border-radius: 4px;
padding: 2px;
font-weight: bold;
font-size: 20px;
}
.off {
display: none;
}
.post-menu-div {
display: inline;
}
a.post-menu-link {
text-decoration: none;
color: green;
}
.rss-logo {
height: 30px;
width: auto;
float: right;
}
#submit-button {
float: right;
}

File diff suppressed because one or more lines are too long

View file

@ -1,26 +0,0 @@
(rule
(target catalog.js)
(deps
(file ../../../js/catalog.bc.js))
(action
(with-stdout-to
%{target}
(cat ../../../js/catalog.bc.js))))
(rule
(target babillard.js)
(deps
(file ../../../js/babillard.bc.js))
(action
(with-stdout-to
%{target}
(cat ../../../js/babillard.bc.js))))
(rule
(target thread.js)
(deps
(file ../../../js/thread.bc.js))
(action
(with-stdout-to
%{target}
(cat ../../../js/thread.bc.js))))

File diff suppressed because it is too large Load diff

View file

@ -1,19 +0,0 @@
# Register
<form>
<div class="mb-3">
<label for="inputNick" class="form-label">Nick</label>
<input type="text" class="form-control" id="inputNick" aria-describedby="nickHelp">
<div id="nickHelp" class="form-text">Who are u ?</div>
</div>
<div class="mb-3">
<label for="inputEmail" class="form-label">Email address</label>
<input type="email" class="form-control" id="inputEmail" aria-describedby="emailHelp">
<div id="emailHelp" class="form-text">We'll never share your email with anyone else.</div>
</div>
<div class="mb-3">
<label for="inputPassword" class="form-label">Password</label>
<input type="password" class="form-control" id="inputPassword">
</div>
<button type="submit" class="btn btn-primary" formaction="/register">Submit</button>
</form>

View file

@ -1,48 +0,0 @@
open Caqti_request.Infix
let db_root = App.data_dir
let () =
match Bos.OS.Dir.create (Fpath.v db_root) with
| Ok true -> Dream.log "created %s" db_root
| Ok false -> Dream.log "%s already exists" db_root
| Error (`Msg _) ->
Dream.warning (fun log -> log "error when creating %s" db_root)
let db = Filename.concat db_root "permap.db"
let db_uri = Format.sprintf "sqlite3://%s" db
module Db =
(val Caqti_blocking.connect (Uri.of_string db_uri) |> Caqti_blocking.or_fail)
let () =
let set_foreign_keys_on =
Caqti_type.(unit ->. unit) "PRAGMA foreign_keys = ON"
in
if Result.is_error (Db.exec set_foreign_keys_on ()) then
Dream.error (fun log -> log "can't set foreign_keys on")
let () =
let query =
Caqti_type.(unit ->. unit)
"CREATE TABLE IF NOT EXISTS dream_session (id TEXT PRIMARY KEY, label \
TEXT NOT NULL, expires_at REAL NOT NULL, payload TEXT NOT NULL)"
in
match Db.exec query () with
| Ok () -> ()
| Error _e ->
Format.eprintf "db error@\n";
exit 1
let unwrap_err = function
| Error e -> Error (Format.sprintf "db error: %s" (Caqti_error.show e))
| Ok _ as ok -> ok
let exec q v = Db.exec q v |> unwrap_err
let find q v = Db.find q v |> unwrap_err
let find_opt q v = Db.find_opt q v |> unwrap_err
let collect_list q v = Db.collect_list q v |> unwrap_err

View file

@ -1,13 +0,0 @@
val exec : ('a, unit, [< `Zero ]) Caqti_request.t -> 'a -> (unit, string) result
val find : ('a, 'b, [< `One ]) Caqti_request.t -> 'a -> ('b, string) result
val find_opt :
('a, 'b, [< `One | `Zero ]) Caqti_request.t
-> 'a
-> ('b option, string) result
val collect_list :
('a, 'b, [ `Many | `One | `Zero ]) Caqti_request.t
-> 'a
-> ('b list, string) result

162
src/db_image.ml Normal file
View file

@ -0,0 +1,162 @@
(* TODO
- delete: error if does not exists
- better upload (insert/update)
- use join *)
open Syntax
open Err
open Types
open Caqti_request.Infix
open Caqti_type
open Caqti_db
let post_tbl_prefix, post_key_reference = ("post_", "post(id)")
let avatar_tbl_prefix, avatar_key_reference = ("user_", "user(user_id)")
let () =
let mk_tables id_kind prefix reference_tbl =
[| Fmt.kstr (unit ->. unit)
"CREATE TABLE IF NOT EXISTS %simage_info (id %s, md5 TEXT, mime TEXT, \
w INTEGER, h INTEGER, thumb_w INTEGER, thumb_h INTEGER, name TEXT, \
alt TEXT, FOREIGN KEY(id) REFERENCES %s ON DELETE CASCADE)"
prefix id_kind reference_tbl
; Fmt.kstr (unit ->. unit)
"CREATE TABLE IF NOT EXISTS %simage_content (id %s, content TEXT, \
FOREIGN KEY(id) REFERENCES %s ON DELETE CASCADE)"
prefix id_kind reference_tbl
; Fmt.kstr (unit ->. unit)
"CREATE TABLE IF NOT EXISTS %simage_thumbnail (id %s, content TEXT, \
FOREIGN KEY(id) REFERENCES %s ON DELETE CASCADE)"
prefix id_kind reference_tbl
|]
in
let tables =
Array.concat
[ mk_tables "INTEGER" post_tbl_prefix post_key_reference
; mk_tables "TEXT" avatar_tbl_prefix avatar_key_reference
]
in
Array.iter (fun query -> Db.exec_unsafe query ()) tables
module type T = sig
type t
val info : t -> img_info option result
val data : t -> string option result
val thumbnail_data : t -> string option result
val delete : t -> unit result
val upload : t -> img -> unit result
end
module type A = sig
type t
val caqti_t : t Caqti_type.t
val prefix : string
val replace_image : bool
end
(* Make(A) => T *)
module Make (M : A) = struct
include M
let upload_info =
Db.exec
@@ Fmt.kstr
(t9 caqti_t string string int int int int string string ->. unit)
"INSERT INTO %simage_info VALUES (?,?,?,?,?,?,?,?,?)" prefix
let upload_data =
Db.exec
@@ Fmt.kstr
(t2 caqti_t string ->. unit)
"INSERT INTO %simage_content VALUES (?,?)" prefix
let upload_thumbnail_data =
Db.exec
@@ Fmt.kstr
(t2 caqti_t string ->. unit)
"INSERT INTO %simage_thumbnail VALUES (?,?)" prefix
let info =
let f =
Db.find_opt
(Fmt.kstr
(caqti_t ->? t9 caqti_t string string int int int int string string)
"SELECT * FROM %simage_info WHERE id=?" prefix )
in
fun id ->
let+ opt = f id in
Option.map
(fun (_id, md5, mime, w, h, thumb_w, thumb_h, name, alt) ->
{ md5; mime; w; h; thumb_w; thumb_h; name; alt } )
opt
let data =
Db.find_opt
@@ Fmt.kstr (caqti_t ->? string)
"SELECT content FROM %simage_content WHERE id=?" prefix
let thumbnail_data =
Db.find_opt
@@ Fmt.kstr (caqti_t ->? string)
"SELECT content FROM %simage_thumbnail WHERE id=?" prefix
let delete_info =
Db.exec
@@ Fmt.kstr (caqti_t ->. unit) "DELETE FROM %simage_info WHERE id=?" prefix
let delete_content =
Db.exec
@@ Fmt.kstr (caqti_t ->. unit) "DELETE FROM %simage_content WHERE id=?"
prefix
let delete_thumbnail =
Db.exec
@@ Fmt.kstr (caqti_t ->. unit) "DELETE FROM %simage_thumbnail WHERE id=?"
prefix
(* TODO error if does not exists *)
let delete id =
let* () = delete_info id in
let* () = delete_content id in
delete_thumbnail id
let upload id image =
(* TODO do something like
https://stackoverflow.com/questions/418898/upsert-not-insert-or-replace/4330694#4330694
instead of deleting then re-inserting to update(or insert on first time).. *)
let* () = if replace_image then delete id else Ok () in
(* -- *)
let { info; data; thumbnail_data } = image in
let { md5; mime; w; h; thumb_w; thumb_h; name; alt } = info in
let* () = upload_info (id, md5, mime, w, h, thumb_w, thumb_h, name, alt) in
let* () = upload_data (id, data) in
upload_thumbnail_data (id, thumbnail_data)
end
module P = Make (struct
type t = int
let caqti_t = Caqti_type.int
let prefix = post_tbl_prefix
let replace_image = false
end)
module U = Make (struct
type t = string
let caqti_t = Caqti_type.string
let prefix = avatar_tbl_prefix
let replace_image = true
end)

28
src/db_image.mli Normal file
View file

@ -0,0 +1,28 @@
(* TODO sql
- use JOIN for getting image info instead *)
(* no sql transaction are started in this module
two kind of images: post images and user avatars
only difference is avatar image are unique and "persist".
kept in different tables
:^) ~ horrible functor just to factorize code
*)
open Err
open Types
module type T = sig
type t
val info : t -> img_info option result
val data : t -> string option result
val thumbnail_data : t -> string option result
val delete : t -> unit result
val upload : t -> img -> unit result
end
module P : T with type t = int
module U : T with type t = string

269
src/db_post.ml Normal file
View file

@ -0,0 +1,269 @@
(* TODO sql
- add index on thread_reply/post_reply
- have a table auto-updated for bump_status
- JOIN :
- use join for image_info
- get all thread reply_l in one join + queries for backlinks
*)
open Syntax
open Err
open Types
open Caqti_request.Infix
open Caqti_type
open Caqti_db
let () =
let tables =
[| (unit ->. unit)
"CREATE TABLE IF NOT EXISTS post (id INTEGER PRIMARY KEY \
AUTOINCREMENT, user_id TEXT, date FLOAT, comment TEXT, FOREIGN \
KEY(user_id) REFERENCES user(user_id) ON DELETE CASCADE)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS thread (thread_id INTEGER, lat FLOAT, lng \
FLOAT, subject TEXT, reply_count INTEGER, last_reply_date FLOAT, \
FOREIGN KEY(thread_id) REFERENCES post(id) ON DELETE CASCADE)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS thread_reply (thread_id INTEGER, post_id \
INTEGER, FOREIGN KEY(thread_id) REFERENCES post(id) ON DELETE \
CASCADE, FOREIGN KEY(post_id) REFERENCES post(id) ON DELETE CASCADE)"
; (unit ->. unit)
(* backlinks *)
"CREATE TABLE IF NOT EXISTS post_reply (id INTEGER, reply_id INTEGER, \
FOREIGN KEY(reply_id) REFERENCES post(id) ON DELETE CASCADE)"
|]
in
let indexes =
[| (unit ->. unit)
"CREATE INDEX IF NOT EXISTS index_thread_bump ON thread \
(last_reply_date)"
|]
in
let triggers =
[| (unit ->. unit)
"CREATE TRIGGER IF NOT EXISTS trigger_incr_reply_count AFTER INSERT \
ON thread_reply BEGIN UPDATE thread SET reply_count = reply_count + \
1 WHERE thread_id = new.thread_id; END;"
; (unit ->. unit)
"CREATE TRIGGER IF NOT EXISTS trigger_decr_reply_count AFTER DELETE \
ON thread_reply BEGIN UPDATE thread SET reply_count = reply_count - \
1 WHERE thread_id = old.thread_id; END;"
; (unit ->. unit)
"CREATE TRIGGER IF NOT EXISTS trigger_update_last_reply_date AFTER \
INSERT ON thread_reply BEGIN UPDATE thread SET last_reply_date = \
(SELECT date FROM post WHERE id = new.post_id) WHERE thread_id = \
new.thread_id; END;"
|]
in
Array.iter (fun query -> Db.exec_unsafe query ()) tables;
Array.iter (fun query -> Db.exec_unsafe query ()) indexes;
Array.iter (fun query -> Db.exec_unsafe query ()) triggers;
()
let add_post =
Db.find
@@ (t3 string float string ->! int)
"INSERT INTO post (user_id,date,comment) VALUES (?,?,?) RETURNING id"
let add_thread_reply =
Db.exec @@ (t2 int int ->. unit) "INSERT INTO thread_reply VALUES (?,?)"
let find_post_w_join =
Db.find_opt
@@ (int ->! t6 int string float string int string)
"SELECT p.id, p.user_id, p.date, p.comment, t_r.thread_id, u.nick FROM \
post p JOIN thread_reply t_r ON p.id=t_r.post_id JOIN user u ON \
p.user_id = u.user_id WHERE p.id = ?"
let find_post_thread_id =
Db.find_opt
@@ (int ->! int) "SELECT thread_id FROM thread_reply WHERE post_id=?"
let add_thread =
Db.exec
@@ (t6 int float float string int float ->. unit)
"INSERT INTO thread VALUES (?,?,?,?,?,?)"
let find_thread =
Db.find_opt
@@ (int ->! t6 int float float string int float)
"SELECT * FROM thread WHERE thread_id=?"
let add_post_reply =
Db.exec @@ (t2 int int ->. unit) "INSERT INTO post_reply VALUES (?,?)"
let get_thread_replies =
Db.collect_list
@@ (int ->* int) "SELECT post_id FROM thread_reply WHERE thread_id = ?"
let get_post_backlinks =
Db.collect_list
@@ (int ->* int) "SELECT reply_id FROM post_reply WHERE id = ?"
let get_all_thread_ids =
Db.collect_list @@ (unit ->* int) "SELECT thread_id FROM thread"
let delete_post = Db.exec @@ (int ->. unit) "DELETE FROM post WHERE id=?"
let get_thread_bump_rank =
Db.find
@@ (int ->! t2 int int)
"SELECT reply_count, CAST (rank AS INTEGER) FROM ( SELECT thread_id, \
reply_count, ROW_NUMBER () OVER ( ORDER BY last_reply_date DESC ) rank \
FROM thread ) WHERE thread_id = ?"
(* ----- *)
let get_thread_bump_rank id =
(* count rank from 0
reply_count = nb_row(thread_reply) - 1; because we don't count op *)
let+ reply_count, rank = get_thread_bump_rank id in
let rank = rank - 1 in
if rank > Config.thread_alive_max_count then Locked rank
else if reply_count >= Config.thread_replies_max_count then Locked rank
else Alive rank
let get_post_thread_id id =
let* opt = find_post_thread_id id in
match opt with
| None -> Error (Internal (Db_not_found (string_of_int id)))
| Some v -> Ok v
let delete id =
Db.do_transaction @@ fun () ->
let* thread_id = get_post_thread_id id in
let is_op = thread_id = id in
match is_op with
| true ->
let* replies = get_thread_replies thread_id in
let* () = list_iter delete_post replies in
Ok ()
| false ->
let+ () = delete_post id in
()
let find_post id =
let* opt = find_post_w_join id in
match opt with
| None -> Ok None
| Some (id, poster_id, date, comment_str, thread_id, poster_nick) ->
let* comment =
Comment.of_string comment_str
|> Result.map_error (fun s -> Unprocessable (Fmt.str "comment: %s" s))
in
let* image_info = Db_image.P.info id in
let+ backlinks = get_post_backlinks id in
Some
{ id
; parent_t_id = thread_id
; date
; poster_id
; poster_nick
; comment
; image_info
; backlinks
}
let get_post id =
let* opt = find_post id in
match opt with
| None -> Error (Internal (Db_not_found (string_of_int id)))
| Some v -> Ok v
let find_thread id =
let* opt = find_thread id in
match opt with
| None -> Ok None
| Some (id, lat, lng, subject, reply_count, _last_reply_date) ->
let* op = get_post id in
let+ bump_status = get_thread_bump_rank id in
Some { op; subject; lat; lng; bump_status; reply_count }
let find_thread_w_reply id =
let* opt = find_thread id in
match opt with
| None -> Ok None
| Some { op; subject; lat; lng; bump_status; reply_count } ->
let+ reply_l =
let* ids = get_thread_replies id in
list_map get_post ids
in
Some
Thread_w_reply.
{ op; subject; lat; lng; bump_status; reply_count; reply_l }
let get_thread id =
let* opt = find_thread id in
match opt with
| None -> Error (Internal (Db_not_found (string_of_int id)))
| Some v -> Ok v
let find_post id = Db.do_transaction @@ fun () -> find_post id
let find_thread id = Db.do_transaction @@ fun () -> find_thread id
let find_thread_w_reply id =
Db.do_transaction @@ fun () -> find_thread_w_reply id
let get_catalog () =
Db.do_transaction @@ fun () ->
let* ids = get_all_thread_ids () in
list_map get_thread ids
let add_post_aux ~thread_id ~thread_data ~user ~image ~comment =
let poster_id = user.user_id in
let poster_nick = user.user_nick in
let date = Unix.time () in
let image_info = Option.map (fun o -> o.info) image in
let comment_str = Comment.to_string comment in
let* id = add_post (poster_id, date, comment_str) in
let thread_id = Option.value ~default:id thread_id in
let* () =
match thread_data with
| None -> Ok ()
| Some (subject, lat, lng) ->
(* don't count op in thread number of replies
-1 because of trigger on thread_reply table insert *)
let nb_reply = -1 in
add_thread (id, lat, lng, subject, nb_reply, date)
in
let* () = add_thread_reply (thread_id, id) in
let* () =
Option.fold ~none:(Ok ()) ~some:(fun img -> Db_image.P.upload id img) image
in
let+ () =
Comment.backlinks comment
|> list_iter (fun cited_id -> add_post_reply (cited_id, id))
in
{ id
; parent_t_id = thread_id
; date
; poster_id
; poster_nick
; comment
; image_info
; backlinks =
[] (* TODO can be false because of possibility to reply to futur post *)
}
let add_post ~thread_id ~user ~image ~comment =
Db.do_transaction @@ fun () ->
let+ post =
add_post_aux ~thread_id:(Some thread_id) ~thread_data:None ~user ~image
~comment
in
post
let add_thread ~subject ~lat ~lng ~user ~image ~comment =
Db.do_transaction @@ fun () ->
let subject : v_string = subject in
let subject :> string = subject in
let thread_data = Some (subject, lat, lng) in
let+ op = add_post_aux ~thread_id:None ~thread_data ~user ~image ~comment in
Thread_w_reply.
{ op
; subject
; lat
; lng
; bump_status = Alive 0
; reply_count = 0
; reply_l = [ op ]
}

28
src/db_post.mli Normal file
View file

@ -0,0 +1,28 @@
open Err
open Types
val find_post : post_id -> post option result
val find_thread : post_id -> thread option result
val find_thread_w_reply : post_id -> Thread_w_reply.t option result
val get_catalog : unit -> thread list result
val delete : post_id -> unit result
val add_post :
thread_id:post_id
-> user:user
-> image:img option
-> comment:comment
-> post result
val add_thread :
subject:v_string
-> lat:float
-> lng:float
-> user:user
-> image:img option
-> comment:comment
-> Thread_w_reply.t result

125
src/db_user.ml Normal file
View file

@ -0,0 +1,125 @@
open Syntax
open Types
open Caqti_request.Infix
open Caqti_type
open Caqti_db
let () =
let tables =
[| (unit ->. unit)
"CREATE TABLE IF NOT EXISTS user (user_id TEXT, nick TEXT, password \
TEXT, email TEXT, bio TEXT, PRIMARY KEY(user_id))"
|]
in
Array.iter (fun query -> Db.exec_unsafe query ()) tables
let get_password_hash =
Db.find @@ (string ->! string) "SELECT password FROM user WHERE user_id=?"
let upload_user =
Db.exec
@@ (t5 string string string string string ->. unit)
"INSERT INTO user VALUES (?, ?, ?, ?, ?)"
let update_bio =
Db.exec @@ (t2 string string ->. unit) "UPDATE user SET bio=? WHERE user_id=?"
let update_nick =
Db.exec
@@ (t2 string string ->. unit) "UPDATE user SET nick=? WHERE user_id=?"
let update_email =
Db.exec
@@ (t2 string string ->. unit) "UPDATE user SET email=? WHERE user_id=?"
let update_password_hash =
Db.exec
@@ (t2 string string ->. unit) "UPDATE user SET password=? WHERE user_id=?"
let delete_user =
Db.exec @@ (string ->. unit) "DELETE FROM user WHERE user_id=?"
let find =
Db.find_opt
@@
(* there is no "tup6" *)
(string ->! t5 string string string string string)
"SELECT * FROM user WHERE user_id=?"
let find_of_nick =
Db.find_opt
@@ (string ->! t5 string string string string string)
"SELECT * FROM user WHERE nick=?"
let find_of_email =
Db.find_opt
@@ (string ->! t5 string string string string string)
"SELECT * FROM user WHERE email=?"
(* ----- *)
let find_user_private_aux f s =
let* opt = f s in
match opt with
| None -> Ok None
| Some (user_id, user_nick, _password, email, bio) ->
let+ avatar_info = Db_image.U.info user_id in
let user_is_admin = List.mem user_nick Config_serv.admin_l in
Some
User_private.
{ user_id; user_nick; user_is_admin; bio; avatar_info; email }
let private_to_public u =
let User_private.
{ user_id; user_nick; user_is_admin; bio; avatar_info; email = _ } =
u
in
{ user_id; user_nick; user_is_admin; bio; avatar_info }
let find_user_aux f s =
let+ opt = find_user_private_aux f s in
Option.map private_to_public opt
let find_user_of_nick s =
Db.do_transaction @@ fun () ->
let s : v_string = s in
find_user_aux find_of_nick (s :> string)
let find_user_of_email s =
Db.do_transaction @@ fun () ->
let s : v_string = s in
find_user_aux find_of_email (s :> string)
let find_user id = Db.do_transaction @@ fun () -> find_user_aux find id
let find_user_private id =
Db.do_transaction @@ fun () -> find_user_private_aux find id
let get_password_hash id = Db.do_transaction @@ fun () -> get_password_hash id
let update_nick user_id s =
Db.do_transaction @@ fun () ->
let s : v_string = s in
update_nick ((s :> string), user_id)
let update_bio user_id s =
Db.do_transaction @@ fun () ->
let s : v_string = s in
update_bio ((s :> string), user_id)
let update_email user_id s =
Db.do_transaction @@ fun () ->
let s : v_string = s in
update_email ((s :> string), user_id)
let update_password_hash user_id password_hash =
Db.do_transaction @@ fun () -> update_password_hash (password_hash, user_id)
let add_user ~email ~nick ~password_hash =
Db.do_transaction @@ fun () ->
let email : v_string = email in
let nick : v_string = nick in
let user_id = Util.gen_uuid () in
upload_user (user_id, (nick :> string), password_hash, (email :> string), "")
let delete_user user_id = Db.do_transaction @@ fun () -> delete_user user_id

25
src/db_user.mli Normal file
View file

@ -0,0 +1,25 @@
open Err
open Types
val find_user : user_id -> user option result
val find_user_of_nick : v_string -> user option result
val find_user_of_email : v_string -> user option result
val find_user_private : user_id -> User_private.t option result
val get_password_hash : user_id -> string result
val update_password_hash : user_id -> string -> unit result
val update_nick : user_id -> v_string -> unit result
val update_bio : user_id -> v_string -> unit result
val update_email : user_id -> v_string -> unit result
val delete_user : user_id -> unit result
val add_user :
email:v_string -> nick:v_string -> password_hash:string -> unit result

View file

@ -1,20 +0,0 @@
let f post_preview post_id request =
<script type="text/javascript" src="/assets/js/catalog.js" defer="defer"></script>
<%s! post_preview %>
% let url = Format.sprintf "/delete/%s" post_id in
% begin match Dream.session "nick" request with
% | None ->
% let redirect = Dream.to_percent_encoded url in
<a href="/login?redirect=<%s redirect%>">Login</a> to delete your post.
% | Some _nick ->
<div class="row mb-3">
<div class="col-md-6" id="delete-form">
<div class="postForm">
<%s! Dream.form_tag ~action:url request %>
<button type="submit" class="btn btn-primary">DELETE</button>
</form>
</div>
</div>
</div>
% end;

View file

@ -1,129 +0,0 @@
open Syntax
(** Creating the table of all messages.
Each message is made of :
- an id (msg_id)
- the id of the sender (from_id)
- the id of the receiver (to_id)
- some text (msg)
TODO: add date ? *)
module Q = struct
open Caqti_request.Infix
open Caqti_type
let create_msg_table =
Db.exec
@@ (unit ->. unit)
"CREATE TABLE IF NOT EXISTS msg ( msg_id TEXT, from_id TEXT, to_id \
TEXT, msg TEXT, PRIMARY KEY(msg_id), FOREIGN KEY(from_id) REFERENCES \
user(user_id) ON DELETE CASCADE, FOREIGN KEY(to_id) REFERENCES \
user(user_id) ON DELETE CASCADE)"
let find_comrades =
Db.collect_list
@@ (tup2 string string ->* tup2 string string)
"SELECT from_id, to_id FROM msg WHERE from_id=? OR to_id=?"
let find_messages =
Db.collect_list
@@ (tup2 (tup2 string string) (tup2 string string) ->* tup2 string string)
"SELECT from_id, msg FROM msg WHERE (from_id=? AND to_id=?) OR \
(from_id=? AND to_id=?)"
let insert_msg =
Db.exec
@@ (tup3 string string string ->. unit)
"INSERT INTO msg VALUES (NULL, ?, ?, ?)"
end
let () =
Result.iter_error
(fun _e -> Dream.error (fun log -> log "can't create table"))
(Q.create_msg_table ())
(** let's find who the user is talking to so we can know if they're dangerous *)
let find_comrades user_id =
let* comrades = Q.find_comrades (user_id, user_id) in
let comrades =
List.map (fun (l, r) -> if l = user_id then r else l) comrades
in
Ok (List.sort_uniq String.compare comrades)
(** find all messages between two товарищи *)
let find_messages k1 k2 = Q.find_messages ((k1, k2), (k2, k1))
(** display the list of discussions *)
let render =
let pp_one_discuss fmt (id, nick) =
Format.fprintf fmt {|<li><a href="/discuss/%s">%s</a></li>|} id nick
in
fun request ->
Utils.logged_in_or_redirect request (fun user_id ->
Utils.render_result request
@@ let* comrades = find_comrades user_id in
let* comrades =
Syntax.unwrap_list
(fun id ->
match User.get_nick id with
| Error _e as e -> e
| Ok nick -> Ok (id, nick) )
comrades
in
Ok
(Format.asprintf "<ul>%a</ul>"
(Format.pp_print_list
~pp_sep:(fun fmt () -> Format.fprintf fmt "<br />")
pp_one_discuss )
comrades ) )
let pp_discussion (request, user_id, comrade_id) =
let path = Format.sprintf "/discuss/%s" comrade_id in
Utils.render_result request
@@ let* msg = find_messages user_id comrade_id in
let* user_nick = User.get_nick user_id in
let* comrade_nick = User.get_nick comrade_id in
let pp_one_msg fmt (from_id, msg) =
Format.fprintf fmt "<li>%s | %s</li>"
(if from_id = user_id then user_nick else comrade_nick)
(Dream.html_escape msg)
in
let pp_all_msg fmt msg =
Format.fprintf fmt "<ul>%a</ul>"
(Format.pp_print_list
~pp_sep:(fun fmt () -> Format.fprintf fmt "<br />")
pp_one_msg )
msg
in
Ok
(Format.asprintf
{|%a<br />
%s
<input value="" name="msg" type="text" />
<button type="submit" class="btn btn-primary">Send</button>
</form>|}
pp_all_msg msg
(Dream.form_tag ~action:path request) )
(** display one discussion *)
let renderone request =
Utils.logged_in_or_redirect request (fun user_id ->
let comrade_id = Dream.param request "comrade_id" in
pp_discussion (request, user_id, comrade_id) )
let insert_msg from_id to_id msg = Q.insert_msg (from_id, to_id, msg)
(** handle posts *)
let post request =
Utils.logged_in_or_redirect request (fun user_id ->
match%lwt Dream.form request with
| `Ok [ ("msg", msg) ] -> begin
let comrade_id = Dream.param request "comrade_id" in
match insert_msg user_id comrade_id msg with
| Ok () -> pp_discussion (request, user_id, comrade_id)
| Error e -> Utils.render e request
end
| form -> Utils.handle_invalid_form form )

161
src/dune
View file

@ -1,30 +1,33 @@
(executable
(public_name permap)
(modules
app
babillard
babillard_page
catalog_page
content
db
delete_page
discuss
image
emojid
login
permap
post_form
pp_babillard
register
report_page
syntax
template
thread_page
user
user_account
user_profile
utils)
(modules permap)
(package permap)
(libraries
config_serv_impl ; implements config_serv
config_impl ; implements config
shared
permap
;;
dream
fmt
fpath
uri
prelude)
(preprocess
(pps lwt_ppx))
(flags
(:standard -open Prelude)))
(library
(name permap)
(wrapped false)
(modules :standard \ permap json_data syntax types err validate_str)
(libraries
config_serv ; virtual
config ; virtual
shared ; virtual
comment
;;
bos
caqti
caqti.blocking
@ -32,97 +35,43 @@
conan
conan.string
conan-database.light
containers-data
directories
digestif
dream
dream-pure
emile
emoji
fpath
lambdasoup
omd
fmt
htmlit
safepass
scfg
uri
uuidm
yojson)
unix
prelude)
(preprocess
(pps lwt_ppx)))
(pps lwt_ppx))
(flags
(:standard -open Prelude)))
(library
(name shared)
(wrapped false)
(modules json_data syntax types err validate_str)
(modules_without_implementation types)
(libraries
config ; virtual
comment
;;
lwt
data-encoding
fmt
prelude)
(flags
(:standard -open Prelude)))
(rule
(targets babillard_page.ml)
(deps babillard_page.eml.html)
(action
(run dream_eml %{deps} --workspace %{workspace_root})))
(rule
(targets catalog_page.ml)
(deps catalog_page.eml.html)
(action
(run dream_eml %{deps} --workspace %{workspace_root})))
(rule
(targets delete_page.ml)
(deps delete_page.eml.html)
(action
(run dream_eml %{deps} --workspace %{workspace_root})))
(rule
(targets login.ml)
(deps login.eml.html)
(action
(run dream_eml %{deps} --workspace %{workspace_root})))
(rule
(targets post_form.ml)
(deps post_form.eml.html)
(action
(run dream_eml %{deps} --workspace %{workspace_root})))
(rule
(targets register.ml)
(deps register.eml.html)
(action
(run dream_eml %{deps} --workspace %{workspace_root})))
(rule
(targets report_page.ml)
(deps report_page.eml.html)
(action
(run dream_eml %{deps} --workspace %{workspace_root})))
(rule
(targets template.ml)
(deps template.eml.html)
(action
(run dream_eml %{deps} --workspace %{workspace_root})))
(rule
(targets thread_page.ml)
(deps thread_page.eml.html)
(action
(run dream_eml %{deps} --workspace %{workspace_root})))
(rule
(targets user_account.ml)
(deps user_account.eml.html)
(action
(run dream_eml %{deps} --workspace %{workspace_root})))
(rule
(targets user_profile.ml)
(deps user_profile.eml.html)
(action
(run dream_eml %{deps} --workspace %{workspace_root})))
(rule
(target content.ml)
(target assets.ml)
(deps
(source_tree content)
(file content/assets/js/babillard.js)
(file content/assets/js/catalog.js)
(file content/assets/js/thread.js))
(source_tree assets)
(file assets/js/client.js))
(action
(with-stdout-to
%{null}
(run ocaml-crunch -m plain content -o %{target}))))
(run ocaml-crunch -m plain assets -o %{target}))))

View file

@ -1,86 +0,0 @@
open Syntax
open Caqti_request.Infix
open Caqti_type
(* todo better: make emojid just string and not string list in this module;
problem is we have to split on unicode *)
module Q = struct
(* we save emojid in a string with emoji separated by '-' *)
let upload_emojid uuid emojid =
let emojid = String.concat "-" emojid in
Db.exec
((tup2 string string ->. unit) "INSERT INTO uuid_emojid VALUES (?,?)")
(uuid, emojid)
let get_emojid uuid =
Db.find
((string ->! string) "SELECT emojid FROM uuid_emojid WHERE uuid=?")
uuid
|> Result.map (String.split_on_char '-')
let get_all_emojid () =
let* l =
Db.collect_list ((unit ->* string) "SELECT emojid FROM uuid_emojid") ()
in
Ok (List.map (String.split_on_char '-') l)
end
module Trie = CCTrie.Make (struct
type t = string list
type char_ = string
let compare = String.compare
let to_iter o f = List.iter f o
let of_list = Fun.id
end)
let max_emojid_lenght = 16
let alphabet =
Array.append Emoji.category_animals_and_nature Emoji.category_food_and_drink
let trie =
let tables =
[| (unit ->. unit)
"CREATE TABLE IF NOT EXISTS uuid_emojid (uuid TEXT, emojid TEXT)"
|]
in
if
Array.exists Result.is_error
(Array.map (fun query -> Db.exec query ()) tables)
then failwith "can't create emojid's tables"
else
match Q.get_all_emojid () with
| Error e ->
failwith (Format.sprintf "Error with Emojid.Q.select_all: %s" e)
| Ok l ->
let l = List.map (fun e -> (e, ())) l in
ref (Trie.of_list l)
let make uuid =
(* pick a list of emojis *)
let random_emojis =
List.init max_emojid_lenght (fun _i ->
let n = Random.int (Array.length alphabet) in
Array.get alphabet n )
in
(* pick the smallest emojid possible *)
let longest_prefix = Trie.longest_prefix random_emojis !trie in
(* add one more emoji to longest_prefix *)
match List.nth_opt random_emojis (List.length longest_prefix) with
| None ->
Dream.error (fun log -> log "Emojid error: longest prefix is too long");
Error "Could not create emojid"
| Some x ->
let emojid = longest_prefix @ [ x ] in
let* () = Q.upload_emojid uuid emojid in
trie := Trie.add emojid () !trie;
Ok (String.concat "" emojid)
let get uuid =
let* l = Q.get_emojid uuid in
Ok (String.concat "" l)

View file

@ -1,5 +0,0 @@
(** [make uuid] creates an emojid for [uuid]; hopefully returns [Ok emojid] *)
val make : string -> (string, string) result
(** [get uuid] is [Ok emoji] if [uuid] has an emojid *)
val get : string -> (string, string) result

46
src/err.ml Normal file
View file

@ -0,0 +1,46 @@
type internal_err =
| No_msg (* used to not expose detail to client *)
| Db of string
| Db_not_found of string
| Bos of string
| Conan of string
type t =
| Internal of internal_err
(* error due to client *)
| Bad_form
| Bad_form_suspicious
| Unauthorized
| Unauthorized_login of string
| Forbidden
| Not_found
| Not_found_thread of int
| Not_found_post of int
| Not_found_user of string
| Not_found_image of int
| Unprocessable of string
type nonrec 'a result = ('a, t) result
let pp_internal fmt = function
| No_msg -> Fmt.pf fmt "no msg"
| Db s -> Fmt.pf fmt "db: %s" s
| Db_not_found s -> Fmt.pf fmt "db (not found): %s" s
| Bos s -> Fmt.pf fmt "bos: %s" s
| Conan s -> Fmt.pf fmt "conan: %s" s
let pp fmt = function
| Internal e -> Fmt.pf fmt "%a" pp_internal e
| Bad_form -> Fmt.pf fmt "bad form"
| Bad_form_suspicious -> Fmt.pf fmt "bad form suspicious"
| Unauthorized -> Fmt.pf fmt "unauthorized"
| Unauthorized_login s -> Fmt.pf fmt "unauthorized login: %s" s
| Forbidden -> Fmt.pf fmt "forbidden"
| Not_found -> Fmt.pf fmt "not found"
| Not_found_thread s -> Fmt.pf fmt "thread not found: %d" s
| Not_found_post s -> Fmt.pf fmt "post not found: %d" s
| Not_found_user s -> Fmt.pf fmt "user not found: %s" s
| Not_found_image s -> Fmt.pf fmt "image not found: %d" s
| Unprocessable s -> Fmt.pf fmt "unprocessable: %s" s
let hide_internal_err_detail = function Internal _ -> Internal No_msg | e -> e

35
src/html.ml Normal file
View file

@ -0,0 +1,35 @@
open Htmlit
let page_to_string page = Htmlit.El.to_string ~doctype:true page
let page_of_res res =
let res_str, msg =
match res with Error msg -> ("Error", msg) | Ok msg -> ("Ok", msg)
in
let title = Fmt.str "%s: %s" res_str msg in
let content = El.div [ El.h1 [ El.txt res_str ]; El.p [ El.txt msg ] ] in
let page =
El.page ~lang:"en" ~styles:[] ~scripts:[] ~more_head:El.void ~title content
in
page
(* TODO have a loading animation *)
let app_start_page =
let title = "Permap" in
let body = El.body [] in
let styles =
let leaflet_style = "/assets/css/leaflet.css" in
let style = "/assets/css/style.css" in
[ leaflet_style; style ]
in
let more_head =
let icon =
El.link
~at:At.[ rel "icon"; type' "image/png"; href "/assets/img/favicon.png" ]
()
in
icon
in
let scripts = [ "/assets/js/client.js" ] in
let page = El.page ~lang:"en" ~styles ~scripts ~more_head ~title body in
page

7
src/html.mli Normal file
View file

@ -0,0 +1,7 @@
open Htmlit.El
val page_to_string : html -> string
val page_of_res : (string, string) result -> html
val app_start_page : html

View file

@ -1,163 +1,87 @@
open Syntax
open Caqti_request.Infix
open Caqti_type
open Err
open Types
type t =
{ name : string
; alt : string
; content : string
; thumbnail : string
}
let read_mime =
let database = Conan.Process.database ~tree:Conan_light.tree in
fun data ->
try
match Conan_string.run ~database data with
| Error (`Msg e) -> Error (Internal (Conan e))
| Ok m -> (
match Conan.Metadata.mime m with
| None -> Error (Unprocessable "no mime found")
| Some mime ->
(* Case Closed ~~! *)
Ok mime )
with _ ->
(* conan is still experimental and can leak exceptions *)
Error (Internal (Conan "conan error"))
let () =
let tables =
[| (unit ->. unit)
"CREATE TABLE IF NOT EXISTS image_info (post_id TEXT, image_name \
TEXT, image_alt TEXT, FOREIGN KEY(post_id) REFERENCES \
post_user(post_id) ON DELETE CASCADE)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS image_content (post_id TEXT, content \
TEXT, FOREIGN KEY(post_id) REFERENCES post_user(post_id) ON DELETE \
CASCADE)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS image_thumbnail (post_id TEXT, content \
TEXT, FOREIGN KEY(post_id) REFERENCES post_user(post_id) ON DELETE \
CASCADE)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS user_image_content (user_id TEXT, content \
TEXT, FOREIGN KEY(user_id) REFERENCES user(user_id) ON DELETE \
CASCADE)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS user_image_thumbnail (user_id TEXT, \
content TEXT, FOREIGN KEY(user_id) REFERENCES user(user_id) ON \
DELETE CASCADE)"
|]
in
if
Array.exists Result.is_error
(Array.map (fun query -> Db.exec query ()) tables)
then Dream.error (fun log -> log "can't create images tables")
let upload_info =
Db.exec
@@ (tup3 string string string ->. unit)
"INSERT INTO image_info VALUES (?,?,?)"
let upload_content =
Db.exec
@@ (tup2 string string ->. unit) "INSERT INTO image_content VALUES (?,?)"
let upload_thumbnail =
Db.exec
@@ (tup2 string string ->. unit) "INSERT INTO image_thumbnail VALUES (?,?)"
let get_content =
Db.find_opt
@@ (string ->? string) "SELECT content FROM image_content WHERE post_id=?"
let get_thumbnail =
Db.find_opt
@@ (string ->? string) "SELECT content FROM image_thumbnail WHERE post_id=?"
let get_info =
Db.find_opt
@@ (string ->? tup2 string string)
"SELECT image_name,image_alt FROM image_info WHERE post_id=?"
let upload_user_content =
Db.exec
@@ (tup2 string string ->. unit) "INSERT INTO user_image_content VALUES (?,?)"
let upload_user_thumbnail =
Db.exec
@@ (tup2 string string ->. unit)
"INSERT INTO user_image_thumbnail VALUES (?,?)"
let get_user_content =
Db.find_opt
@@ (string ->? string)
"SELECT content FROM user_image_content WHERE user_id=?"
let get_user_thumbnail =
Db.find_opt
@@ (string ->? string)
"SELECT content FROM user_image_thumbnail WHERE user_id=?"
let delete_user_content =
Db.exec @@ (string ->. unit) "DELETE FROM user_image_content WHERE user_id=?"
let delete_user_thumbnail =
Db.exec
@@ (string ->. unit) "DELETE FROM user_image_thumbnail WHERE user_id=?"
let upload image id =
let* () = upload_info (id, image.name, image.alt) in
let* () = upload_content (id, image.content) in
upload_thumbnail (id, image.thumbnail)
let upload_avatar image id =
let* () = delete_user_content id in
let* () = delete_user_thumbnail id in
let* () = upload_user_content (id, image.content) in
upload_user_thumbnail (id, image.thumbnail)
let make_thumbnail content =
let magick =
let open Bos in
(* jpp *)
let ( let* ) o f =
Result.fold ~ok:f ~error:(function `Msg s -> Result.error s) o
let strip_exif file =
let cmd = Cmd.(v "magick" % p file % "-strip" % p file) in
OS.Cmd.(run cmd)
in
let* image_file = OS.File.tmp "%s" in
let* thumb_file = OS.File.tmp "%s_thumb" in
let* () = OS.File.write image_file content in
let make_thumbnail in_file out_file =
let cmd =
Cmd.(
v "convert" % "-define" % "jpeg:size=700x700" % p image_file
v "magick" % "-define" % "jpeg:size=700x700" % p in_file
% "-auto-orient" % "-thumbnail" % "300x300>" % "-unsharp" % "0x.5"
% "-format" % "jpg" % p thumb_file )
% "-format" % "jpg" % p out_file )
in
let* () = OS.Cmd.run cmd in
OS.Cmd.run cmd
in
let read_dimension file =
let cmd = Cmd.(v "magick" % "identify" % "-format" % "%w#%h" % p file) in
let* s = OS.Cmd.(run_out cmd |> out_string |> success) in
match String.split_on_char '#' s |> List.map int_of_string_opt with
| [] -> assert false
| [ Some w; Some h ] -> Ok (w, h)
| _ -> Fmt.error_msg "magick identify, invalid format"
in
fun data ->
Result.map_error (function `Msg s -> Internal (Bos s))
@@
let* image_file = OS.File.tmp "%s" in
let* thumb_file = OS.File.tmp "%s_thumb" in
let res =
let* () = OS.File.write image_file data in
let* () = strip_exif image_file in
let* () = make_thumbnail image_file thumb_file in
let* image = OS.File.read image_file in
let* thumbnail = OS.File.read thumb_file in
let* img_dim = read_dimension image_file in
let* thumb_dim = read_dimension thumb_file in
Ok ((image, img_dim), (thumbnail, thumb_dim))
in
let* () = OS.File.delete image_file in
let* () = OS.File.delete thumb_file in
Ok thumbnail
res
let mime =
let database = Conan.Process.database ~tree:Conan_light.tree in
fun content ->
match Conan_string.run ~database content with
| Ok m -> Conan.Metadata.mime m
| Error _ -> None
let make_image image =
let max_name = 1000 in
let max_alt = 3000 in
let max_content = 4200000 in
let name, alt, content = image in
let name =
match name with
| Some name -> Dream.html_escape name
| None ->
(* make up random name if no name was given *)
Uuidm.to_string (Uuidm.v4_gen App.random_state ())
in
let alt = if String.trim alt = "" then name else alt in
if String.length name > max_name then
Error (Format.sprintf "Image name too long: More than %dB" max_name)
else if String.length alt > max_alt then
Error (Format.sprintf "Image description too long: More than %dB" max_alt)
else if String.length content > max_content then
Error (Format.sprintf "Image size too big: More than %dB" max_content)
let build ~name ~alt data =
let* name = Validate_str.image_name name in
let* alt = Validate_str.image_alt alt in
let* () =
let data_len = String.length data in
if data_len <= Config.image_max_size then Ok ()
else
match mime content with
| None -> Error "invalid image type"
| Some mime -> (
match mime with
| "image/jpeg" | "image/png" | "image/webp" | "image/gif" -> (
match make_thumbnail content with
| Error e -> Error e
| Ok thumbnail -> Ok { name; alt; content; thumbnail } )
| _unsupported_mime_type ->
Error (Format.sprintf "unsupported image type: %s" mime) )
let s =
Fmt.str "Image is too big (%a), maximum size is %a" Fmt.bi_byte_size
data_len Fmt.bi_byte_size Config.image_max_size
in
Error (Unprocessable s)
in
let* mime = read_mime data in
let* () =
match Array.mem mime Config.supported_mime_type with
| true -> Ok ()
| false -> Error (Unprocessable (Fmt.str "unsupported image type: %s" mime))
in
let+ (data, (w, h)), (thumbnail_data, (thumb_w, thumb_h)) = magick data in
let md5 = Digestif.MD5.(to_hex (digest_string data)) in
let info =
{ md5; mime; w; h; thumb_w; thumb_h; name :> string; alt :> string }
in
{ info; data; thumbnail_data }

5
src/image.mli Normal file
View file

@ -0,0 +1,5 @@
open Err
open Types
(* validate data, make thumbnail *)
val build : name:string -> alt:string -> string -> img result

View file

@ -1,124 +0,0 @@
open Brr
open Utils
open Map
module Visibility = struct
let new_thread_div = find_by_id "new-thread"
let thread_comment = find_by_id "comment"
let thread_preview_div = find_by_id "thread-preview"
let return_button = find_by_id "return-button"
(* new-thread-button is new-thread-button-redirect if not logged in *)
let new_thread_button = find_by_id_opt "new-thread-button"
let is_in_new_thread_mode = ref false
let set_visible el =
log "set_visible@\n";
El.set_class (Jstr.of_string "off") false el
let set_invisible el =
log "set_invisible@\n";
El.set_class (Jstr.of_string "off") true el
let to_new_thread_mode _event =
log "change_page_mode@\n";
is_in_new_thread_mode := true;
set_visible new_thread_div;
set_visible return_button;
set_invisible thread_preview_div;
Option.iter set_invisible new_thread_button;
El.set_has_focus true thread_comment;
Leaflet.Map.close_popup ~popup:None map
let to_babillard_mode _event =
log "change_page_mode@\n";
is_in_new_thread_mode := false;
set_invisible new_thread_div;
set_invisible return_button;
set_visible thread_preview_div;
Option.iter set_visible new_thread_button;
Leaflet.Map.close_popup ~popup:None map
let () =
log "add events on return/new thread button@\n";
let (_ : Ev.listener) =
Ev.listen Ev.click to_babillard_mode (El.as_target return_button)
in
Option.iter
(fun button ->
let (_ : Ev.listener) =
Ev.listen Ev.click to_new_thread_mode (El.as_target button)
in
() )
new_thread_button
end
module Marker = struct
let thread_preview_div = find_by_id "thread-preview"
let marker_on_click thread_preview _e =
log "marker_on_click@\n";
if not !Visibility.is_in_new_thread_mode then (
let inner_html = El.Prop.jstr (Jstr.of_string "innerHTML") in
El.set_prop inner_html thread_preview thread_preview_div;
Pretty_post.make_pretty () )
let on_each_feature feature layer =
log "on_each_feature@\n";
let feature_properties = Jv.get feature "properties" in
let thread_preview = Jv.get feature_properties "content" |> Jv.to_jstr in
Leaflet.Layer.on Leaflet.Event.Click (marker_on_click thread_preview) layer
let handle_geojson geojson =
log "handle_geojson@\n";
let layer =
Leaflet.Layer.create_geojson geojson [ On_each_feature on_each_feature ]
in
let _marker_layer = Leaflet.Layer.add_to map layer in
()
let markers_handle_response response =
log "markers_handle_response@\n";
let geo_json_list_futur = Jv.call response "json" [||] in
ignore @@ Jv.call geo_json_list_futur "then" [| Jv.repr handle_geojson |]
let () =
log "fetch thread geojson@\n";
let link = Jv.of_string "/markers" in
(* todo: fetch with Brr *)
let window = Jv.get Jv.global "window" in
let fetchfutur = Jv.call window "fetch" [| link |] in
ignore @@ Jv.call fetchfutur "then" [| Jv.repr markers_handle_response |]
end
let lat_input = find_by_id "lat-input"
let lng_input = find_by_id "lng-input"
let button = find_by_id "submit-button"
(* set input lat/lng when clicked*)
let on_click_set_latlng e =
log "on_click_set_latlng@\n";
if !Visibility.is_in_new_thread_mode then (
let latlng = Leaflet.Event.latlng e in
let popup =
Leaflet.Popup.create ~content:(Some "create thread here")
~latlng:(Some latlng) []
in
Leaflet.Map.open_popup popup map;
(* TODO add a marker with special icon here *)
let lat = Leaflet.Latlng.lat latlng |> Jstr.of_float in
let lng = Leaflet.Latlng.lng latlng |> Jstr.of_float in
let value_jstr = Jstr.of_string "value" in
El.set_at value_jstr (Some lat) lat_input;
El.set_at value_jstr (Some lng) lng_input;
El.set_at (Jstr.of_string "disabled") None button )
(*add on_click callback to map*)
let () = Leaflet.Map.on Leaflet.Event.Click on_click_set_latlng map

View file

View file

@ -1,51 +0,0 @@
(library
(name utils)
(modules utils)
(libraries brr)
(preprocess
(pps js_of_ocaml-ppx)))
(library
(name post_form)
(modules post_form)
(libraries js_of_ocaml brr utils)
(preprocess
(pps js_of_ocaml-ppx)))
(library
(name pretty_post)
(modules pretty_post)
(libraries js_of_ocaml brr unix utils)
(preprocess
(pps js_of_ocaml-ppx)))
(library
(name map)
(modules map)
(libraries js_of_ocaml brr leaflet utils)
(preprocess
(pps js_of_ocaml-ppx)))
(executable
(name catalog)
(modules catalog)
(libraries js_of_ocaml brr pretty_post)
(modes js)
(preprocess
(pps js_of_ocaml-ppx)))
(executable
(name babillard)
(modules babillard)
(libraries js_of_ocaml brr map post_form pretty_post leaflet utils)
(modes js)
(preprocess
(pps js_of_ocaml-ppx)))
(executable
(name thread)
(modules thread)
(libraries js_of_ocaml brr post_form pretty_post)
(modes js)
(preprocess
(pps js_of_ocaml-ppx)))

View file

@ -1,99 +0,0 @@
open Utils
let map = Leaflet.Map.create_on "map"
let () =
let osm_layer = Leaflet.Layer.create_tile_osm None in
Leaflet.Layer.add_to map osm_layer
let storage = Brr_io.Storage.local Brr.G.window
(* set map's view *)
(* try to set map's view to last position viewed by using web storage *)
let () =
log "setting view@\n";
let lat = Brr_io.Storage.get_item storage (Jstr.of_string "lat") in
let lng = Brr_io.Storage.get_item storage (Jstr.of_string "lng") in
let zoom = Brr_io.Storage.get_item storage (Jstr.of_string "zoom") in
match (lat, lng, zoom) with
| Some lat, Some lng, Some zoom ->
let lat = Jstr.to_float lat in
let lng = Jstr.to_float lng in
let zoom =
match Jstr.to_int zoom with
| None -> failwith "view storage bug"
| Some zoom -> Some zoom
in
let latlng = Leaflet.Latlng.create lat lng in
ignore @@ Leaflet.Map.set_view latlng ~zoom map
| _ ->
let latlng = Leaflet.Latlng.create 51.505 (-0.09) in
ignore @@ Leaflet.Map.set_view latlng ~zoom:(Some 13) map
let on_moveend _event =
log "on moveend event@\n";
let latlng = Leaflet.Map.get_center map in
(*we need to wrap coordinates so we don't drift into a parralel universe and lose track of markers :^) *)
let wrapped_latlng = Leaflet.Map.wrap_latlng latlng map in
let lat = Leaflet.Latlng.lat latlng |> Jv.of_float |> Jv.to_jstr in
let lng = Leaflet.Latlng.lng latlng |> Jv.of_float |> Jv.to_jstr in
match Brr_io.Storage.set_item storage (Jstr.of_string "lat") lat with
| (exception Jv.Error _) | Error _ -> failwith "can't set latlng storage"
| Ok () -> (
match Brr_io.Storage.set_item storage (Jstr.of_string "lng") lng with
| (exception Jv.Error _) | Error _ -> failwith "can't set latlng storage"
| Ok () ->
let is_wrapped = not @@ Leaflet.Latlng.equals latlng wrapped_latlng in
if is_wrapped then (
log "setView to wrapped coordinate@\n";
(* warning: calling setView in on_moveend can cause recursion *)
Leaflet.Map.set_view wrapped_latlng ~zoom:None map ) )
let on_zoomend _event =
log "on zoomend event@\n";
let zoom = Leaflet.Map.get_zoom map in
match
Brr_io.Storage.set_item storage (Jstr.of_string "zoom") (Jstr.of_int zoom)
with
| (exception Jv.Error _) | Error _ -> failwith "can't set latlng storage"
| Ok () -> ()
let () =
log "add on (move/zoom)end event@\n";
Leaflet.Map.on Leaflet.Event.Move_end on_moveend map;
Leaflet.Map.on Leaflet.Event.Zoom_end on_zoomend map
module Geolocalize = struct
let update_location geo =
log "update_location@\n";
match geo with
| Error _ -> failwith "error in geolocation"
| Ok geo ->
let lat = Brr_io.Geolocation.Pos.latitude geo in
let lng = Brr_io.Geolocation.Pos.longitude geo in
let latlng = Leaflet.Latlng.create lat lng in
Leaflet.Map.set_view latlng ~zoom:(Some 13) map
let geolocalize _ =
log "geolocalize@\n";
let update_location geo =
log "update_location@\n";
match geo with
| Error e ->
(* todo: popup error message for user *)
log "geolocation failure: %s@\n"
@@ Jstr.to_string
@@ Brr_io.Geolocation.Error.message e
| Ok geo ->
(* todo: add a special marker to map *)
let lat = Brr_io.Geolocation.Pos.latitude geo in
let lng = Brr_io.Geolocation.Pos.longitude geo in
let latlng = Leaflet.Latlng.create lat lng in
Leaflet.Map.set_view latlng ~zoom:(Some 17) map
in
let l = Brr_io.Geolocation.of_navigator Brr.G.navigator in
let opts = Brr_io.Geolocation.opts ~high_accuracy:true () in
(* todo: use `Geolocation.watch` instead ? it may improve precision *)
ignore @@ Fut.await (Brr_io.Geolocation.get l ~opts) update_location
end

View file

@ -1,3 +0,0 @@
open Js_map
let log = Format.printf

View file

@ -1,52 +0,0 @@
open Brr
open Utils
(* called by clicking post_id *)
(* insert emojid into reply form *)
let insert_quote el _event =
log "quote@\n";
let emojid =
match El.at (Jstr.of_string "data-emojid") el with
| None -> failwith "no data-emojid on element"
| Some emojid -> Jstr.to_string emojid
in
match find_by_id_opt "comment" with
| None -> log "element `comment` not found, not logged in?@\n"
| Some textarea ->
let value = El.Prop.value in
let content = Jstr.to_string @@ El.prop value textarea in
let new_content =
if String.ends_with ~suffix:"\n" content || String.length content = 0 then
(* don't skip a line *)
Format.sprintf "%s[>%s] " content emojid
else Format.sprintf "%s@\n[>%s] " content emojid
in
El.set_prop value (Jstr.of_string new_content) textarea;
El.set_has_focus true textarea
(* make image description field visible when a file is selected*)
let make_visible el _event = El.set_class (Jstr.of_string "off") false el
let add_events _load =
log "add post_form events @\n";
match find_by_id_opt "file" with
| None -> log "element `file` not found, not logged in?@\n"
| Some file_input ->
let alt_input = find_by_id "alt" in
let alt_label = find_by_id "alt-label" in
let change = Ev.Type.create (Jstr.of_string "change") in
let (_ : Ev.listener) =
Ev.listen change (make_visible alt_input) (El.as_target file_input)
in
let (_ : Ev.listener) =
Ev.listen change (make_visible alt_label) (El.as_target file_input)
in
log "add inser_quote event on post links@\n";
add_event_to_class Ev.click "quote-link" insert_quote
(*make events after page load*)
let () =
let (_ : Ev.listener) =
Ev.listen Ev.load add_events (Window.as_target G.window)
in
()

View file

@ -1,203 +0,0 @@
open Brr
open Utils
type image_size =
| Big
| Small
let of_string = function
| "post-image" -> Some Small
| "post-image-big" -> Some Big
| _ -> None
let to_string = function Small -> "post-image" | Big -> "post-image-big"
(*change postImage class to make it bigger/smaller on click*)
let image_click post_image event =
log "image_click@\n";
let class_jstr = Jstr.of_string "class" in
let current_class =
match El.at class_jstr post_image with
| None -> failwith "no class for post_image"
| Some c -> Jstr.to_string c
in
let new_class =
match of_string current_class with
| Some image_size -> ( match image_size with Big -> Small | Small -> Big )
| None -> failwith "invalid image class name"
in
El.set_at class_jstr (Some (Jstr.of_string (to_string new_class))) post_image;
let id =
match El.at (Jstr.of_string "data-id") post_image with
| None -> failwith "no data-id on post_image"
| Some id -> Jstr.to_string id
in
let src =
match new_class with
| Small -> Format.sprintf "/img/s/%s" id
| Big -> Format.sprintf "/img/%s" id
in
El.set_at (Jstr.of_string "src") (Some (Jstr.of_string src)) post_image;
(*prevent redirect to /img/:img*)
Ev.prevent_default event;
Ev.stop_propagation event
let render_time date_span =
log "render time@\n";
let data_time =
match El.at (Jstr.of_string "data-time") date_span with
| None -> failwith "no attribute data-time for date element"
| Some data_time -> Jstr.to_float data_time
in
let t = Unix.localtime data_time in
let date =
Format.sprintf "%02d-%02d-%02d %02d:%02d" (1900 + t.tm_year) (1 + t.tm_mon)
t.tm_mday t.tm_hour t.tm_min
in
let inner_html = El.Prop.jstr (Jstr.of_string "innerHTML") in
El.set_prop inner_html (Jstr.of_string date) date_span
let preview_ref = ref None
let highlighted_ref = ref None
let selected_ref = ref None
let on_hashchange _event =
log "on hashchange";
let frag = Jstr.to_string @@ Uri.fragment @@ Window.location G.window in
if frag = "" then ()
else
match find_by_id_opt frag with
| None -> log "fragment not found on the page"
| Some reply ->
let () =
match !selected_ref with
| None -> ()
| Some item -> El.set_class (Jstr.of_string "selected") false item
in
El.set_class (Jstr.of_string "selected") true reply;
selected_ref := Some reply
let clone_element el =
(* TODO: how to clone with Brr? *)
let id =
match El.at (Jstr.of_string "id") el with
| None -> failwith "element as no id for cloning"
| Some id -> Jstr.to_string id
in
(* get reply_div as a Jv.t *)
let original_div = Jv.get Jv.global id in
let div = Jv.call original_div "cloneNode" [| Jv.of_bool true |] in
ignore
@@ Jv.call div "setAttribute"
[| Jv.of_string "id"; Jv.of_string "floating-reply-preview" |];
ignore
@@ Jv.call div "setAttribute"
[| Jv.of_string "class"; Jv.of_string "post highlight" |];
(* append to DOM *)
(* we needs to add it to `body` and not `original_div` or it might change the display
* and do buggy things with mouse events on `original_div`*)
let document = Jv.get Jv.global "document" in
let body = Jv.get document "body" in
ignore @@ Jv.call body "append" [| div |];
(* go back to El *)
match find_by_id_opt "floating-reply-preview" with
| None -> failwith "error cloning element"
| Some el -> el
let on_mouse_over el _event =
log "on mouse over@\n";
let reply_id =
match El.at (Jstr.of_string "data-id") el with
| None -> failwith "no data-id on element"
| Some data_id -> Jstr.to_string data_id
in
match find_by_id_opt reply_id with
| None -> failwith "error getting reply_div, this reply is not on this page"
| Some reply_div ->
(* check if it in view, if it is, just make it of class `highlight` *)
let window_height =
let window = Jv.get Jv.global "window" in
Jv.get window "innerHeight" |> Jv.to_int
in
let reply_top = El.bound_y reply_div |> int_of_float in
if reply_top < window_height - 50 && reply_top + 50 > 0 then (
(* just highlight if reply is in viewport *)
El.set_class (Jstr.of_string "highlight") true reply_div;
highlighted_ref := Some reply_div )
else
(* copy it to make new div `floating-reply-preview` *)
let preview_div = clone_element reply_div in
(* place it next to the reply-link el*)
let top =
let el_top = El.bound_y el in
let h = El.bound_h preview_div in
(* clamp to viewport *)
let top =
Float.min
(el_top -. (0.5 *. h))
(float_of_int window_height -. h -. 7.0)
in
let top = Float.max top 0.0 in
top |> int_of_float |> Format.sprintf "%dpx" |> Jstr.of_string
in
let left =
El.bound_x el +. El.bound_w el
|> int_of_float |> Format.sprintf "%dpx" |> Jstr.of_string
in
El.set_inline_style El.Style.position (Jstr.of_string "fixed") preview_div;
El.set_inline_style El.Style.z_index (Jstr.of_string "42") preview_div;
El.set_inline_style El.Style.top top preview_div;
El.set_inline_style El.Style.left left preview_div;
(* also highlight class doesn't work if we set inline style idk why wtf css *)
El.set_inline_style El.Style.background_color (Jstr.of_string "#9dd162")
preview_div;
(* set preview_div ref for on_mouse_out *)
preview_ref := Some preview_div
let on_mouse_out _el _event =
log "on mouse out@\n";
(* get the `reply-preview` element, delete it if Some*)
let () =
match !highlighted_ref with
| None -> ()
| Some highlighted_div ->
El.set_class (Jstr.of_string "highlight") false highlighted_div
in
match !preview_ref with
| None -> ()
| Some preview_div -> El.remove preview_div
let make_pretty _event =
log "make pretty@\n";
let dates = El.find_by_class (Jstr.of_string "date") in
List.iter render_time dates;
(*add event image_click to all postImage*)
let () = add_event_to_class Ev.click "post-image" image_click in
(*add event mouse_over/out to all reply-link *)
let () = add_event_to_class Ev.mouseover "reply-link" on_mouse_over in
let () = add_event_to_class Ev.mouseout "reply-link" on_mouse_out in
(* add fragment listener to mark as selected the linked post *)
let (_ : Ev.listener) =
Ev.listen Ev.hashchange on_hashchange (Window.as_target G.window)
in
(* call hashchange on page load too *)
on_hashchange ()
(*make pretty after page load*)
let () =
log "add load eventlistener to make pretty@\n";
let (_ : Ev.listener) =
Ev.listen Ev.load make_pretty (Window.as_target G.window)
in
()

View file

View file

@ -1,18 +0,0 @@
open Brr
let log = Format.printf
let find_by_id_opt id = Document.find_el_by_id G.document (Jstr.of_string id)
let find_by_id id =
match find_by_id_opt id with
| None -> failwith (Format.sprintf "element `%s` not found" id)
| Some el -> el
let add_event_to_class event name handler =
let el_list = El.find_by_class (Jstr.of_string name) in
List.iter
(fun el ->
let (_ : Ev.listener) = Ev.listen event (handler el) (El.as_target el) in
() )
el_list

364
src/json_data.ml Normal file
View file

@ -0,0 +1,364 @@
open Data_encoding
open Types
type nonrec 'a result = ('a, string) result
let internal_err =
let open Err in
union
[ (let title = "No_msg" in
case ~title (Tag 0)
(obj1 (req title unit))
(function No_msg -> Some () | _ -> None)
(fun () -> No_msg) )
; (let title = "Db" in
case ~title (Tag 1)
(obj1 (req title string))
(function Db s -> Some s | _ -> None)
(fun s -> Db s) )
; (let title = "Db_not_found" in
case ~title (Tag 2)
(obj1 (req title string))
(function Db_not_found s -> Some s | _ -> None)
(fun s -> Db_not_found s) )
; (let title = "Bos" in
case ~title (Tag 3)
(obj1 (req title string))
(function Bos s -> Some s | _ -> None)
(fun s -> Bos s) )
; (let title = "Conan" in
case ~title (Tag 4)
(obj1 (req title string))
(function Conan s -> Some s | _ -> None)
(fun s -> Conan s) )
]
let err =
let open Err in
union
[ (let title = "Internal" in
case ~title (Tag 0) internal_err
(function Internal o -> Some o | _ -> None)
(fun o -> Internal o) )
; (let title = "Bad_form" in
case ~title (Tag 1)
(obj1 (req title unit))
(function Bad_form -> Some () | _ -> None)
(fun () -> Bad_form) )
; (let title = "Bad_form_suspicious" in
case ~title (Tag 2)
(obj1 (req title unit))
(function Bad_form_suspicious -> Some () | _ -> None)
(fun () -> Bad_form_suspicious) )
; (let title = "Unauthorized" in
case ~title (Tag 3)
(obj1 (req title unit))
(function Unauthorized -> Some () | _ -> None)
(fun () -> Unauthorized) )
; (let title = "Unauthorized_login" in
case ~title (Tag 4)
(obj1 (req title string))
(function Unauthorized_login s -> Some s | _ -> None)
(fun s -> Unauthorized_login s) )
; (let title = "Forbidden" in
case ~title (Tag 5)
(obj1 (req title unit))
(function Forbidden -> Some () | _ -> None)
(fun () -> Forbidden) )
; (let title = "Not_found" in
case ~title (Tag 6)
(obj1 (req title unit))
(function Not_found -> Some () | _ -> None)
(fun () -> Not_found) )
; (let title = "Not_found_thread" in
case ~title (Tag 7)
(obj1 (req title int31))
(function Not_found_thread s -> Some s | _ -> None)
(fun s -> Not_found_thread s) )
; (let title = "Not_found_post" in
case ~title (Tag 8)
(obj1 (req title int31))
(function Not_found_post s -> Some s | _ -> None)
(fun s -> Not_found_post s) )
; (let title = "Not_found_user" in
case ~title (Tag 9)
(obj1 (req title string))
(function Not_found_user s -> Some s | _ -> None)
(fun s -> Not_found_user s) )
; (let title = "Not_found_image" in
case ~title (Tag 10)
(obj1 (req title int31))
(function Not_found_image s -> Some s | _ -> None)
(fun s -> Not_found_image s) )
; (let title = "Unprocessable" in
case ~title (Tag 11)
(obj1 (req title string))
(function Unprocessable s -> Some s | _ -> None)
(fun s -> Unprocessable s) )
]
let img_info =
conv
(fun { md5; mime; w; h; thumb_w; thumb_h; name; alt } ->
(md5, mime, w, h, thumb_w, thumb_h, name, alt) )
(fun (md5, mime, w, h, thumb_w, thumb_h, name, alt) ->
{ md5; mime; w; h; thumb_w; thumb_h; name; alt } )
(obj8 (req "md5" string) (req "mime" string) (req "w" int31) (req "h" int31)
(req "thumb_w" int31) (req "thumb_h" int31) (req "name" string)
(req "alt" string) )
let post =
conv_with_guard
(fun { id
; parent_t_id
; date
; poster_id
; poster_nick
; comment
; image_info
; backlinks
} ->
( id
, parent_t_id
, date
, poster_id
, poster_nick
, Comment.to_string comment
, image_info
, backlinks ) )
(fun ( id
, parent_t_id
, date
, poster_id
, poster_nick
, comment_str
, image_info
, backlinks ) ->
let open Syntax in
let+ comment = Comment.of_string comment_str in
{ id
; parent_t_id
; date
; poster_id
; poster_nick
; comment
; image_info
; backlinks
} )
(obj8 (req "id" int31) (req "parent_t_id" int31) (req "date" float)
(req "poster_id" string) (req "poster_nick" string)
(req "comment" string)
(req "image_info" (option img_info))
(req "replies" (list int31)) )
let bump_status =
union
[ case ~title:"Dead" (Tag 0)
(obj1 (req "dead" empty))
(function Dead -> Some () | _ -> None)
(fun () -> Dead)
; case ~title:"Locked" (Tag 1)
(obj1 (req "locked" int31))
(function Locked c -> Some c | _ -> None)
(fun c -> Locked c)
; case ~title:"Alive" (Tag 2)
(obj1 (req "alive" int31))
(function Alive c -> Some c | _ -> None)
(fun c -> Alive c)
]
let thread =
conv
(fun { op; subject; lat; lng; bump_status; reply_count } ->
(op, subject, lat, lng, bump_status, reply_count) )
(fun (op, subject, lat, lng, bump_status, reply_count) ->
{ op; subject; lat; lng; bump_status; reply_count } )
(obj6 (req "op" post) (req "subject" string) (req "lat" float)
(req "lng" float)
(req "bump_status" bump_status)
(req "reply_count" int31) )
let thread_w_reply =
let open Thread_w_reply in
conv
(fun { op; subject; lat; lng; bump_status; reply_count; reply_l } ->
(op, subject, lat, lng, bump_status, reply_count, reply_l) )
(fun (op, subject, lat, lng, bump_status, reply_count, reply_l) ->
{ op; subject; lat; lng; bump_status; reply_count; reply_l } )
(obj7 (req "op" post) (req "subject" string) (req "lat" float)
(req "lng" float)
(req "bump_status" bump_status)
(req "reply_count" int31)
(req "reply_l" (list post)) )
let catalog : thread list encoding =
conv (fun l -> l) (fun l -> l) (obj1 (req "catalog" (list thread)))
let report =
conv
(fun { report_id
; report_date
; reported_post
; reporter_user_id
; reporter_nick
; reason
} ->
( report_id
, report_date
, reported_post
, reporter_user_id
, reporter_nick
, reason ) )
(fun ( report_id
, report_date
, reported_post
, reporter_user_id
, reporter_nick
, reason ) ->
{ report_id
; report_date
; reported_post
; reporter_user_id
; reporter_nick
; reason
} )
(obj6 (req "report_id" string) (req "report_date" float)
(req "reported_post" post)
(req "reporter_user_id" string)
(req "reporter_nick" string)
(req "reason" string) )
let reports : report list encoding =
conv (fun o -> o) (fun o -> o) (obj1 (req "reports" (list report)))
let user =
conv
(fun { user_id; user_nick; user_is_admin; bio; avatar_info } ->
(user_id, user_nick, user_is_admin, bio, avatar_info) )
(fun (user_id, user_nick, user_is_admin, bio, avatar_info) ->
{ user_id; user_nick; user_is_admin; bio; avatar_info } )
(obj5 (req "user_id" string) (req "user_nick" string)
(req "user_is_admin" bool) (req "bio" string)
(req "avatar_info" (option img_info)) )
let user_private =
let open User_private in
conv
(fun { user_id; user_nick; user_is_admin; bio; avatar_info; email } ->
(user_id, user_nick, user_is_admin, bio, avatar_info, email) )
(fun (user_id, user_nick, user_is_admin, bio, avatar_info, email) ->
{ user_id; user_nick; user_is_admin; bio; avatar_info; email } )
(obj6 (req "user_id" string) (req "user_nick" string)
(req "user_is_admin" bool) (req "bio" string)
(req "avatar_info" (option img_info))
(req "email" string) )
let geojson_marker : (float * float * post_id) encoding =
let geometry =
conv
(* !! geojson coordinates are lng first then lat *)
(fun (lat, lng) -> ((), [ lng; lat ]) )
(fun ((), coordinates) ->
match coordinates with [ lng; lat ] -> (lat, lng) | _ -> assert false )
(obj2
(req "type" (constant "Point"))
(req "coordinates" (Fixed.list 2 float)) )
in
let properties = conv (fun id -> id) (fun id -> id) (obj1 (req "id" int31)) in
conv
(fun (lat, lng, id) -> ((), (lat, lng), id))
(fun ((), (lat, lng), id) -> (lat, lng, id))
(obj3
(req "type" (constant "Feature"))
(req "geometry" geometry)
(req "properties" properties) )
let geojson_markers : (float * float * post_id) list encoding =
conv
(fun l -> ((), l))
(fun ((), l) -> l)
(obj2
(req "type" (constant "FeatureCollection"))
(req "features" (list geojson_marker)) )
let session : session encoding =
conv
(fun { user_private; csrf_token; csrf_time_limit } ->
(user_private, csrf_token, csrf_time_limit) )
(fun (user_private, csrf_token, csrf_time_limit) ->
{ user_private; csrf_token; csrf_time_limit } )
(obj3
(req "user_private" (option user_private))
(req "csrf_token" string)
(req "csrf_time_limit" float) )
let unit : unit encoding =
conv (fun o -> o) (fun o -> o) (obj1 (req "unit" unit))
let to_string enc =
let json = Data_encoding.Json.construct enc in
fun v -> Data_encoding.Json.to_string (json v)
let of_string enc =
let destruct = Data_encoding.Json.destruct enc in
fun s -> Data_encoding.Json.from_string s |> Result.map destruct
module Read = struct
let err = of_string err
let session = of_string session
let img_info = of_string img_info
let post = of_string post
let bump_status = of_string bump_status
let thread = of_string thread
let thread_w_reply = of_string thread_w_reply
let catalog = of_string catalog
let reports = of_string reports
let user = of_string user
let user_private = of_string user_private
let geojson_marker = of_string geojson_marker
let geojson_markers = of_string geojson_markers
let unit = of_string unit
end
module Write = struct
let err = to_string err
let session = to_string session
let img_info = to_string img_info
let post = to_string post
let bump_status = to_string bump_status
let thread = to_string thread
let thread_w_reply = to_string thread_w_reply
let catalog = to_string catalog
let reports = to_string reports
let user = to_string user
let user_private = to_string user_private
let geojson_marker = to_string geojson_marker
let geojson_markers = to_string geojson_markers
let unit = to_string unit
end

68
src/json_data.mli Normal file
View file

@ -0,0 +1,68 @@
(* TODO
- clean up unused
- expose pp?
- test (later)
*)
open Types
type nonrec 'a result = ('a, string) result
module Read : sig
val err : string -> Err.t result
val img_info : string -> img_info result
val post : string -> post result
val bump_status : string -> bump_status result
val thread : string -> thread result
val thread_w_reply : string -> Thread_w_reply.t result
val catalog : string -> thread list result
val reports : string -> report list result
val user : string -> user result
val user_private : string -> User_private.t result
val geojson_marker : string -> (float * float * post_id) result
val geojson_markers : string -> (float * float * post_id) list result
val session : string -> session result
val unit : string -> unit result
end
module Write : sig
val err : Err.t -> string
val img_info : img_info -> string
val post : post -> string
val bump_status : bump_status -> string
val thread : thread -> string
val thread_w_reply : Thread_w_reply.t -> string
val catalog : thread list -> string
val reports : report list -> string
val user : user -> string
val user_private : User_private.t -> string
val geojson_marker : float * float * post_id -> string
val geojson_markers : (float * float * post_id) list -> string
val session : session -> string
val unit : unit -> string
end

View file

@ -1,20 +0,0 @@
let f request =
% let url =
% match Dream.query request "redirect" with
% | None -> "/login"
% | Some r ->
% Format.sprintf "/login?redirect=%s" r
% in
<%s! Dream.form_tag ~action:url request %>
<div class="mb-3">
<label for="login" class="form-label">Nick</label>
<input name="login" type="text" class="form-control" id="login" aria-describedby="login-help">
<div id="login-help" class="form-text">What is you nickname or email?</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input name="password" type="password" class="form-control" id="password">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>

107
src/moderation.ml Normal file
View file

@ -0,0 +1,107 @@
open Syntax
open Types
open Caqti_request.Infix
open Caqti_type
open Caqti_db
(* TODO do something for multiples report on same post
- don't allow multiple report on same post from same user
- add TEST *)
let () =
let tables =
[| (unit ->. unit)
"CREATE TABLE IF NOT EXISTS report (report_id TEXT, date FLOAT, \
post_id INTEGER, user_id TEXT, reason TEXT, FOREIGN KEY(post_id) \
REFERENCES post(id) ON DELETE CASCADE, FOREIGN KEY(user_id) \
REFERENCES user(user_id) ON DELETE CASCADE)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS banished (nick TEXT, email TEXT)"
|]
in
Array.iter (fun query -> Db.exec_unsafe query ()) tables
module Q = struct
let upload_report =
Db.exec
((t5 string float int string string ->. unit)
"INSERT INTO report VALUES (?,?,?,?,?)" )
let get_reports_all =
Db.collect_list
((unit ->* t6 string float int string string string)
"SELECT r.report_id, r.date, r.post_id, r.user_id, u.nick, reason \
FROM report r JOIN user u ON r.user_id = u.user_id" )
let get_reports_made_by =
Db.collect_list
((string ->* t6 string float int string string string)
"SELECT r.report_id, r.date, r.post_id, r.user_id, u.nick, reason \
FROM report r JOIN user u ON r.user_id = u.user_id WHERE r.user_id=?" )
let delete_report =
Db.exec @@ (int ->. unit) "DELETE FROM report WHERE post_id=?"
let upload_banished =
Db.exec @@ (t2 string string ->. unit) "INSERT INTO banished VALUES (?,?)"
let find_banished =
Db.find_opt
@@ (t2 string string ->! t2 string string)
"SELECT * FROM banished WHERE nick=? OR email=?"
end
let get_report_aux
( report_id
, report_date
, reported_post_id
, reporter_user_id
, reporter_nick
, reason ) =
let+ reported_post = Post.get_post reported_post_id in
{ report_id
; report_date
; reported_post
; reporter_user_id
; reporter_nick
; reason
}
let get_reports_all () =
Db.do_transaction @@ fun () ->
let* l = Q.get_reports_all () in
list_map get_report_aux l
let get_reports_made_by user_id =
Db.do_transaction @@ fun () ->
let* l = Q.get_reports_made_by user_id in
list_map get_report_aux l
let make_report ~reporter_user_id ~reason reported_post_id =
let* reason = Validate_str.report reason in
(* check post exists *)
let* _post = Post.get_post reported_post_id in
let report_date = Unix.time () in
let report_id = Util.gen_uuid () in
Db.do_transaction @@ fun () ->
Q.upload_report
( report_id
, report_date
, reported_post_id
, reporter_user_id
, (reason :> string) )
(* todo sql: no need to use transaction for a single query? *)
let delete_report id = Db.do_transaction @@ fun () -> Q.delete_report id
let is_banished login =
let+ opt = Db.do_transaction @@ fun () -> Q.find_banished (login, login) in
match opt with None -> false | Some _ -> true
(* it would be better to also invalidate banned user's session here
since Api.get_logged_user check for user existance it should be fine *)
let banish user_id =
let* user_private = User.get_user_private user_id in
let* () = User.delete_user user_id in
Db.do_transaction @@ fun () ->
Q.upload_banished (user_private.user_nick, user_private.email)

View file

@ -1,436 +1,151 @@
open Utils
open Syntax
(* TODO http cache *)
let cache_max_age = 0
(* TODO https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html *)
let log_err =
let open Err in
let _debug ~request e = Dream.debug (fun log -> log ~request "%a" pp e) in
let info ~request e = Dream.info (fun log -> log ~request "%a" pp e) in
let warning ~request e = Dream.warning (fun log -> log ~request "%a" pp e) in
let error ~request e = Dream.error (fun log -> log ~request "%a" pp e) in
fun request (e : Err.t) ->
if Config_serv.custom_logger then
match e with
| Internal _ -> error ~request e
| Bad_form_suspicious | Unauthorized_login _ -> warning ~request e
| _ -> info ~request e
let status_of_err (err : Err.t) =
let open Err in
match err with
| Internal _ -> `Internal_Server_Error
| Bad_form | Bad_form_suspicious -> `Bad_Request
| Unauthorized | Unauthorized_login _ -> `Unauthorized
| Forbidden -> `Forbidden
| Not_found | Not_found_thread _ | Not_found_post _ | Not_found_user _
| Not_found_image _ ->
`Not_Found
| Unprocessable _ -> `Bad_Request
let render_result_img request ~headers res =
let headers = [ ("Content-Type", "image") ] @ headers in
match res with
| Ok v -> Dream.respond ~headers v
| Error e ->
log_err request e;
let e = Err.hide_internal_err_detail e in
let status = status_of_err e in
let body = Fmt.str "%a" Err.pp e in
Dream.respond ~headers ~status body
let render_result_json request ~headers res =
match res with
| Ok v -> Dream.json ~headers v
| Error e ->
log_err request e;
let e = Err.hide_internal_err_detail e in
let status = status_of_err e in
let body = Json_data.Write.err e in
Dream.json ~headers ~status body
let asset_loader _root path _request =
match Content.read ("assets/" ^ path) with
match Assets.read path with
| None -> Dream.empty `Not_Found
| Some asset ->
Dream.respond ~headers:[ ("Cache-Control", "max-age=151200") ] asset
let page name request =
match Content.read (name ^ ".md") with
| None -> Dream.empty `Not_Found
| Some page ->
let content = Omd.of_string page |> Omd.to_html in
render content request
let about request = page "about" request
let register_get request = render (Register.f request) request
let register_post request =
match%lwt Dream.form request with
| `Ok [ ("email", email); ("nick", nick); ("password", password) ] -> (
match User.register ~email ~nick ~password with
| Error e -> render e request
| Ok () ->
let res =
Result.fold ~error:Fun.id
~ok:(fun _ -> "User created ! Welcome !")
(User.login ~login:nick ~password request)
in
render res request )
| form -> Utils.handle_invalid_form form
let login_get request = render (Login.f request) request
let login_post request =
match%lwt Dream.form request with
| `Ok [ ("login", login); ("password", password) ] -> (
match User.login ~login ~password request with
| Error e -> render e request
| Ok () ->
let url =
match Dream.query request "redirect" with
| None -> "/"
| Some redirect -> Dream.from_percent_encoded redirect
in
Dream.respond ~status:`See_Other
~headers:[ ("Location", url) ]
"Logged in: Happy geo-posting!" )
| form -> Utils.handle_invalid_form form
let admin_get request =
match Dream.session "user_id" request with
| None ->
let redirect_url =
Format.sprintf "/login?redirect=%s" (Dream.to_percent_encoded "/admin")
in
Dream.respond ~status:`See_Other ~headers:[ ("Location", redirect_url) ] ""
| Some user_id ->
if not (User.is_admin user_id) then Dream.respond ~status:`Forbidden ""
else
let res =
match Babillard.get_reports () with
| Error e -> e
| Ok (posts, reports) ->
Pp_babillard.admin_page_content posts reports request
in
render res request
let admin_post request =
Utils.logged_in_or_redirect request (fun user_id ->
if not (User.is_admin user_id) then Dream.respond ~status:`Forbidden ""
else
match%lwt Dream.form request with
| `Ok [ ("action", action); ("post_id", id) ] -> (
(* TODO: use let* and Utils.render_result ? *)
let res =
match Babillard.get_post id with
| Error _e as e -> e
| Ok post -> (
let evil_user_id = post.user_id in
match Babillard.moderation_action_from_string action with
| None -> Error "Invalid action"
| Some action -> (
match action with
| Delete -> Babillard.try_delete_post ~user_id:evil_user_id id
| Banish -> User.banish evil_user_id
| Ignore -> Babillard.ignore_report id ) )
in
match res with
| Error e -> render e request
| Ok () ->
(* TODO: ??? *)
Dream.respond ~status:`See_Other
~headers:[ ("Location", "/admin") ]
"" )
| form -> Utils.handle_invalid_form form )
let catalog request =
let catalog_content =
Result.fold ~ok:Fun.id ~error:Fun.id (Pp_babillard.catalog_content ())
in
render (Catalog_page.f catalog_content) request
let delete_get request =
let post_id = Dream.param request "post_id" in
let post_preview =
Result.fold ~ok:Fun.id ~error:Fun.id (Pp_babillard.view_post post_id)
in
render (Delete_page.f post_preview post_id request) request
let delete_post request =
Utils.logged_in_or_redirect request (fun user_id ->
(* match on Dream.form needed for hidden csrf field *)
match%lwt Dream.form request with
| `Ok [] -> (
(* TODO: use let* and Utils.render_result ? *)
let post_id = Dream.param request "post_id" in
match Babillard.try_delete_post ~user_id post_id with
| Error e -> render e request
| Ok () ->
Dream.respond ~status:`See_Other
~headers:[ ("Location", "/") ]
"Your post was deleted!" )
| form -> Utils.handle_invalid_form form )
let report_get request =
let post_id = Dream.param request "post_id" in
let post_preview =
Result.fold ~ok:Fun.id ~error:Fun.id (Pp_babillard.view_post post_id)
in
render (Report_page.f post_preview post_id request) request
let report_post request =
Utils.logged_in_or_redirect request (fun user_id ->
match%lwt Dream.form request with
| `Ok [ ("reason", reason) ] ->
Utils.render_result request
@@
let post_id = Dream.param request "post_id" in
let* () = Babillard.report ~user_id ~reason post_id in
Ok "The post was reported!"
| form -> Utils.handle_invalid_form form )
let user request =
render (Result.fold ~ok:Fun.id ~error:Fun.id (User.list ())) request
let user_profile request =
let nick = Dream.param request "user" in
match User.get_id_from_nick nick with
| Error _e -> Dream.respond ~status:`Not_Found "User does not exists"
| Ok user_id -> render_result request @@ User.public_profile user_id
let logout request =
let _ = Dream.invalidate_session request in
let content = "Logged out !" in
render content request
let account_get request =
Utils.logged_in_or_redirect request (fun user_id ->
Utils.render_result request
@@ let* user = User.get_user user_id in
Ok (User_account.f user request) )
(*TODO ask for password *)
let account_post request =
Utils.logged_in_or_redirect request (fun user_id ->
match%lwt Dream.form request with
| `Ok [ ("delete", _) ] ->
Utils.render_result request
@@ (*TODO ask for confirmation *)
let* () = User.delete_user user_id in
let _unit_lwt = Dream.invalidate_session request in
Ok "Your account was deleted"
| `Ok [ ("email", email) ] ->
Utils.render_result request
@@ let* () = User.update_email email user_id in
Ok "Your email was updated!"
| `Ok
[ ("confirm-new-password", confirm_password)
; ("new-password", password)
] ->
Utils.render_result request
@@
if password = confirm_password then
let* () = User.update_password password user_id in
Ok "Your password was updated!"
else Error "Password confirmation does not match"
| form -> Utils.handle_invalid_form form )
let profile_get request =
Utils.logged_in_or_redirect request (fun user_id ->
Utils.render_result request
@@ let* user = User.get_user user_id in
Ok (User_profile.f user request) )
let profile_post request =
Utils.logged_in_or_redirect request (fun user_id ->
match%lwt Dream.form request with
| `Ok [ ("bio", bio) ] -> (
match User.update_bio bio user_id with
| Ok () ->
Dream.respond ~status:`See_Other
~headers:[ ("Location", "/profile") ]
"Your bio was updated!"
| Error e -> render e request )
| `Ok [ ("nick", nick) ] -> (
match User.update_nick nick user_id with
| Ok () ->
Dream.respond ~status:`See_Other
~headers:[ ("Location", "/profile") ]
"Your display nick was updated!"
| Error e -> render e request )
| `Ok [ ("content", content); ("count", count); ("label", label) ] -> (
match int_of_string_opt count with
| None -> render "Error: invalid count" request
| Some count -> (
match User.update_metadata count label content user_id with
| Ok () ->
Dream.respond ~status:`See_Other
~headers:[ ("Location", "/profile") ]
"Your display nick was updated!"
| Error e -> render e request ) )
| `Ok _ -> Dream.respond ~status:`Bad_Request "invalid form"
| `Many_tokens _ | `Missing_token _ | `Invalid_token _ | `Wrong_session _
| `Expired _ | `Wrong_content_type -> (
(* TODO: why is this here ?! *)
match%lwt Dream.multipart request with
| `Ok [ ("file", file) ] -> (
match User.upload_avatar file user_id with
| Ok () ->
Dream.respond ~status:`See_Other
~headers:[ ("Location", "/profile") ]
"Your avatar was updated!"
| Error e -> render e request )
| form -> Utils.handle_invalid_form form ) )
Dream.respond
~headers:[ ("Cache-Control", Fmt.str "max-age=%d" cache_max_age) ]
asset
let get_post_image ~thumbnail request =
let id = Dream.param request "id" in
let image =
(if thumbnail then Image.get_thumbnail else Image.get_content) id
(* posts images do not change so we cache them
todo don't cache if not found? *)
let headers =
[ ("Cache-Control", Fmt.str "max-age=%d, immutable" cache_max_age) ]
in
match image with
| Error e -> render e request
| Ok image_opt -> (
match image_opt with
| None -> Dream.respond ~status:`Not_Found "Image does not exists"
| Some image ->
(* posts images do not change so we cache them *)
Dream.respond
~headers:
[ ("Cache-Control", "max-age=3628800, immutable")
; ("Content-Type", "image")
]
image )
let f = if thumbnail then Post.get_thumbnail_data else Post.get_image_data in
let res =
let* image_id = Api.url_param request Post_image_id in
f image_id
in
render_result_img request ~headers res
let get_avatar_image request =
let nick = Dream.param request "user" in
match User.get_id_from_nick nick with
| Error _e -> Dream.respond ~status:`Not_Found "User does not exists"
| Ok user_id -> (
let avatar = Image.get_user_content user_id in
match avatar with
| Ok (Some avatar) ->
Dream.respond ~headers:[ ("Content-Type", "image") ] avatar
| Ok None -> (
match Content.read "/assets/img/default_avatar.png" with
| None -> failwith "can't find default avatar"
| Some avatar ->
Dream.respond ~headers:[ ("Content-Type", "image") ] avatar )
| Error e -> render e request )
let markers request =
let markers = Pp_babillard.get_markers () in
match markers with
| Ok markers ->
Dream.respond ~headers:[ ("Content-Type", "application/json") ] markers
| Error e -> render e request
let babillard_get request = render (Babillard_page.f request) request
let babillard_post request =
Utils.logged_in_or_redirect request (fun user_id ->
match%lwt Dream.multipart request with
| `Ok
[ ("alt", [ (_, alt) ])
; ("category", categories)
; ("comment", [ (_, comment) ])
; ("file", file)
; ("lat-input", [ (_, lat) ])
; ("lng-input", [ (_, lng) ])
; ("subject", [ (_, subject) ])
; ("tags", [ (_, tags) ])
]
| `Ok
( ("alt", [ (_, alt) ])
:: ("comment", [ (_, comment) ])
:: ("file", file)
:: ("lat-input", [ (_, lat) ])
:: ("lng-input", [ (_, lng) ])
:: ("subject", [ (_, subject) ])
:: ("tags", [ (_, tags) ])
:: ([] as categories) ) -> (
let categories = List.map snd categories in
match (Float.of_string_opt lat, Float.of_string_opt lng) with
| None, _ -> render "Invalide coordinate" request
| _, None -> render "Invalide coordinate" request
| Some lat, Some lng -> (
let op_or_reply_data = `Op_data (categories, subject, lat, lng) in
let res =
match file with
| [] -> Babillard.make_post ~comment ~tags ~op_or_reply_data user_id
| _ :: _ :: _ -> Error "More than one image"
| [ (image_name, image_content) ] ->
let image_input = (image_name, alt, image_content) in
Babillard.make_post ~comment ~image_input ~tags ~op_or_reply_data
user_id
let* user_id = Api.url_param request User_id in
User.get_image user_id
in
match res with
| Ok thread_id ->
let adress = Format.asprintf "/thread/%s" thread_id in
Dream.respond ~status:`See_Other
~headers:[ ("Location", adress) ]
"Your thread was posted!"
| Error e -> render e request ) )
| form -> Utils.handle_invalid_form form )
render_result_img request ~headers:[] res
let thread_feed_get request =
let thread_id = Dream.param request "thread_id" in
match Pp_babillard.feed thread_id with
| Error e -> render e request
| Ok feed ->
Dream.respond ~headers:[ ("Content-Type", "application/atom+xml") ] feed
(* -- ~~ -- *)
let thread_get request =
let thread_id = Dream.param request "thread_id" in
let thread_view = Pp_babillard.view_thread thread_id in
render
( match thread_view with
| Error e -> e
| Ok thread_view -> Thread_page.f thread_view thread_id request )
request
(*form to reply to a thread *)
let reply_post request =
Utils.logged_in_or_redirect request (fun user_id ->
match%lwt Dream.multipart request with
| `Ok
[ ("alt", [ (_, alt) ])
; ("comment", [ (_, comment) ])
; ("file", file)
; ("tags", [ (_, tags) ])
] -> (
let parent_id = Dream.param request "thread_id" in
let op_or_reply_data = `Reply_data parent_id in
let res =
match file with
| [] -> Babillard.make_post ~comment ~tags ~op_or_reply_data user_id
| [ (image_name, image_content) ] ->
let image_input = (image_name, alt, image_content) in
Babillard.make_post ~comment ~image_input ~tags ~op_or_reply_data
user_id
| _ :: _ :: _ -> Error "More than one image"
in
match res with
| Ok post_id ->
let adress = Format.sprintf "/thread/%s#%s" parent_id post_id in
Dream.respond ~status:`See_Other
~headers:[ ("Location", adress) ]
"Your reply was posted!"
| Error e -> render e request )
| form -> Utils.handle_invalid_form form )
let error_template _error _debug_info response =
let status = Dream.status response in
let code = Dream.status_to_int status in
(*TODO improve: can't use template.elm.html because it needs "request" *)
let%lwt body = Dream.body response in
let reason =
if String.equal "" body then Dream.status_to_string status else body
in
Dream.set_body response (Format.sprintf "%d: %s" code reason);
Lwt.return response
let app_start_page _request =
Html.app_start_page |> Html.page_to_string |> Dream.html
let routes =
(* this is just so that they're visually aligned *)
let get_ = Dream.get in
let post = Dream.post in
(* wrap api function with render_result_json *)
let get_ path f =
let handler request = render_result_json request ~headers:[] (f request) in
Dream.get path handler
in
let post path f =
let handler request =
Lwt.bind (f request) (render_result_json request ~headers:[])
in
Dream.post path handler
in
[ get_ "/" babillard_get
; post "/" babillard_post
; get_ "/about" about
; get_ "/account" account_get
; post "/account" account_post
; get_ "/admin" admin_get
; post "/admin" admin_post
; get_ "/assets/**" (Dream.static ~loader:asset_loader "")
; get_ "/catalog" catalog
; get_ "/delete/:post_id" delete_get
; post "/delete/:post_id" delete_post
; get_ "/discuss" Discuss.render
; get_ "/discuss/:comrade_id" Discuss.renderone
; post "/discuss/:comrade_id" Discuss.post
; get_ "/img/:id" (get_post_image ~thumbnail:false)
; get_ "/img/s/:id" (get_post_image ~thumbnail:true)
; get_ "/login" login_get
; post "/login" login_post
; get_ "/logout" logout
; get_ "/markers" markers
; get_ "/profile" profile_get
; post "/profile" profile_post
; get_ "/report/:post_id" report_get
; post "/report/:post_id" report_post
; get_ "/thread/:thread_id" thread_get
; post "/thread/:thread_id" reply_post
; get_ "/thread/:thread_id/feed" thread_feed_get
; get_ "/user" user
; get_ "/user/:user" user_profile
; get_ "/user/:user/avatar" get_avatar_image
(* TODO
- origin_referrer_check
- custom CSRF header instead of having client insert csrf in form *)
Dream.scope "/api" [ (* middlewear *) ]
Api.
[ get_ "/catalog" GET.catalog
; get_ "/thread/:thread_id" GET.thread_w_reply
; get_ "/post/:post_id" GET.post
; get_ "/admin" GET.admin
; get_ "/user/:user_id" GET.user_page
; get_ "/session" GET.session
; post "/register" POST.register
; post "/login" POST.login
; post "/admin/ignore/:post_id" POST.admin_ignore
; post "/admin/delete/:post_id" POST.admin_delete
; post "/admin/banish/:user_id" POST.admin_banish
; post "/profile" POST.profile
; post "/account" POST.account
; post "/logout" POST.logout
; post "/" POST.new_thread
; post "/thread/:thread_id" POST.reply
; post "/delete/:post_id" POST.delete
; post "/report/:post_id" POST.report
]
:: [ Dream.get "/assets/**" (Dream.static ~loader:asset_loader "")
; Dream.get "/img/:image_id" (get_post_image ~thumbnail:false)
; Dream.get "/img/s/:image_id" (get_post_image ~thumbnail:true)
; Dream.get "/user/:user_id/avatar" get_avatar_image
; Dream.get "/**" app_start_page
]
@
if App.open_registration then
[ get_ "/register" register_get; post "/register" register_post ]
else []
let () =
let logger = if App.log then Dream.logger else Fun.id in
Dream.run ~port:App.port ~error_handler:(Dream.error_template error_template)
@@ logger @@ Dream.cookie_sessions
(* this should replace memory/cookie sessions but it doesn't work :-(
@@ Dream.sql_pool Db.db_uri
@@ Dream.sql_sessions
*)
let () =
let open Config_serv in
Dream.log "config file: %a" Fpath.pp config_path;
Dream.log "default_logger: %b" default_logger;
Dream.log "custom_logger: %b" custom_logger
in
let () =
let open Config in
Dream.log "open_registration: %b" open_registration;
Dream.log "hostname: %s" hostname;
Dream.log "port: %d" port
in
()
let () =
let logger = if Config_serv.default_logger then Dream.logger else Fun.id in
Dream.run ~port:Config.port
@@ logger
@@ Dream.sql_pool (Uri.to_string Config_serv.db_uri)
@@ Dream.sql_sessions ~lifetime:(float_of_int Config.session_lifetime)
@@ Dream.router routes

84
src/post.ml Normal file
View file

@ -0,0 +1,84 @@
open Syntax
open Types
open Err
let get_post id =
let* opt = Db_post.find_post id in
match opt with None -> Error (Not_found_post id) | Some v -> Ok v
let get_thread id =
let* opt = Db_post.find_thread id in
match opt with None -> Error (Not_found_thread id) | Some v -> Ok v
let get_thread_w_reply id =
let* opt = Db_post.find_thread_w_reply id in
match opt with None -> Error (Not_found_thread id) | Some v -> Ok v
let get_catalog () = Db_post.get_catalog ()
(* todo id type is string here.. *)
let get_thumbnail_data id =
let* opt = Db_image.P.thumbnail_data id in
match opt with None -> Error (Not_found_image id) | Some image -> Ok image
let get_image_data id =
let* opt = Db_image.P.data id in
match opt with None -> Error (Not_found_image id) | Some image -> Ok image
let get_image_info id =
let* opt = Db_image.P.info id in
match opt with None -> Error (Not_found_image id) | Some image -> Ok image
let delete ~user id =
let* post = get_post id in
if user.user_is_admin || String.equal post.poster_id user.user_id then
Db_post.delete id
else Error Forbidden
let not_empty image_opt comment =
if Option.is_some image_opt || String.length comment <> 0 then Ok ()
else Error (Unprocessable "Your post must contain an image or a comment")
let build_image image_data =
match image_data with
| None -> Ok None
| Some (name_opt, alt, content) ->
let name = Option.value ~default:"" name_opt in
let+ image = Image.build ~name ~alt content in
Some image
let build_comment comment =
(* todo: move validation to Comment.parse *)
let* _comment = Validate_str.comment comment in
let+ comment =
Comment.of_string comment
|> Result.map_error (fun s -> Unprocessable (Fmt.str "comment: %s" s))
in
comment
let make_post ~comment ~image_data ~parent_thread user =
let* () = not_empty image_data comment in
let* thread_id =
match parent_thread.bump_status with
| Dead -> Error (Unprocessable "This thread is dead, you cannot reply.")
| Locked _rank ->
Error (Unprocessable "This thread is locked, you cannot reply.")
| Alive _rank -> Ok parent_thread.op.id
in
let* comment = build_comment comment in
let* image = build_image image_data in
let+ post = Db_post.add_post ~thread_id ~user ~image ~comment in
post
let make_thread ~comment ~image_data ~subject ~lat ~lng user =
let* () = not_empty image_data comment in
let* subject = Validate_str.subject subject in
let* () =
(* TODO latlng validation *)
let is_valid_latlng = true in
if is_valid_latlng then Ok () else Error (Unprocessable "Invalid coordinate")
in
let* comment = build_comment comment in
let* image = build_image image_data in
let+ thread = Db_post.add_thread ~subject ~lat ~lng ~user ~image ~comment in
thread

View file

@ -1,34 +0,0 @@
let f thread_id request =
% let action = match thread_id with |None -> "/" | Some id -> Format.sprintf "/thread/%s" id in
% let checkboxes = match thread_id with |None -> Format.asprintf "%a" Pp_babillard.pp_checkboxes () | Some _id -> "" in
<div class="post-form">
<%s! Dream.form_tag ~action ~enctype:`Multipart_form_data request %>
% begin if Option.is_none thread_id then
<input type="hidden" id="lat-input" name="lat-input">
<input type="hidden" id="lng-input" name="lng-input">
<label for="subject" id="subject-label" class="form-label">Subject</label>
<input name="subject" type="text" class="form-control" id="subject" aria-labelledby="subject-label" />
% end;
<label for="comment" id="comment-label" class="form-label">Comment</label>
<textarea name="comment" type="text" class="form-control" id="comment" aria-labelledby="comment-label"></textarea>
<label for="tags" id="tags-label" class="form-label">Tags</label>
<%s! checkboxes %>
<input name="tags" type="text" class="form-control" id="tags" aria-labelledby="tags-label" />
<label for="file" id="file-label" class="form-label">Add picture</label>
<input id="file" class="form-control" name="file" aria-describedby="file-label" type="file" accept="image/png,image/jpeg,image/webp,image/gif">
<br />
<label for="alt" id="alt-label" class="form-label off">Image description</label>
<input name="alt" type="text" class="form-control off" id="alt" aria-labelledby="alt-label" />
% begin match thread_id with
% | None ->
<br />
<button type="submit" class="btn btn-primary" id="submit-button" disabled>Make Thread</button>
% | Some _id ->
<button type="submit" class="btn btn-primary" id="submit-button">Reply</button>
% end;
</form>
</div>

View file

@ -1,364 +0,0 @@
open Syntax
open Babillard
let pp_post fmt t =
let thread_data_opt, post =
match t with
| Op (data, post) -> (Some data, post)
| Post post -> (None, post)
in
let { id
; emojid
; parent_id = _parent_id
; date
; user_id
; nick
; comment
; image_info
; tags
; replies
; citations = _citations
} =
post
in
let image_view fmt () =
match image_info with
| Some (_image_name, image_alt) ->
Format.fprintf fmt
{|
<div class="post-image-container">
<a href="/img/%s">
<img class="post-image" src="/img/s/%s" alt="%s" title="%s" data-id="%s" loading="lazy">
</a>
</div>
|}
id id image_alt image_alt id
| None -> Format.fprintf fmt ""
in
let pp_print_reply fmt reply =
Format.fprintf fmt {|<a class="reply-link" href="#%s">&gt;&gt;%s</a>|} reply
reply
in
let pp_print_replies fmt replies =
Format.fprintf fmt {|<div class="replies">%a</div>|}
(Format.pp_print_list ~pp_sep:Format.pp_print_space pp_print_reply)
replies
in
let replies_view fmt () =
if Option.is_some thread_data_opt then
(* TODO put thread_posts count in thread_info ? *)
let res_nb = Q.count_thread_posts id in
match res_nb with
| Error _ -> Format.fprintf fmt ""
| Ok ((1 | 2) as nb) ->
Format.fprintf fmt {|<div class="replies">%d reply</div>|} (nb - 1)
| Ok nb ->
Format.fprintf fmt {|<div class="replies">%d replies</div>|} (nb - 1)
else pp_print_replies fmt replies
in
let post_links_view fmt () =
if Option.is_some thread_data_opt then
Format.fprintf fmt {|
%a
|} replies_view ()
else
Format.fprintf fmt
{|
<span class=postNo>
<button data-id="%s" data-emojid="%s" class="quote-link" title="Reply to this post">%s</button>
</span>
%a
|}
id emojid emojid replies_view ()
in
let post_info_view fmt () =
Format.fprintf fmt
{|
<div class="post-info">
<span class="nick" data-user-id="%s">%s</span>
<span class="date" data-time="%f"></span>
<div class="dropend post-menu-div">
<a class="dropdown-toggle post-menu-link" href="#" role="button" id="dropdownMenuLink" data-bs-toggle="dropdown" aria-expanded="false">
</a>
<ul class="dropdown-menu post-menu-content" aria-labelledby="dropdownMenuLink">
<li><a class="dropdown-item" href="#%s">Link to this post</a></li>
<li><a class="dropdown-item" href="/delete/%s">Delete</a></li>
<li><a class="dropdown-item" href="/report/%s">Report</a></li>
</ul>
</div>
%a
</div>|}
user_id nick date id id id post_links_view ()
in
let pp_print_category fmt category =
Format.fprintf fmt {|<span class="category tag">%s</span>|} category
in
let pp_print_tag fmt tag =
Format.fprintf fmt {|<span class="tag">%s</span>|} tag
in
let pp_print_tags fmt tags =
let categories, tags =
List.partition (fun tag -> List.mem tag App.categories) tags
in
let categories = List.sort String.compare categories in
let tags = List.sort String.compare tags in
let pp_sep = Format.pp_print_space in
Format.fprintf fmt {|<div class="tags">%a%a</div>|}
(Format.pp_print_list ~pp_sep pp_print_category)
categories
(Format.pp_print_list ~pp_sep pp_print_tag)
tags
in
let tags = List.sort String.compare tags in
let tags_view fmt () = pp_print_tags fmt tags in
let pp_subject fmt () =
match thread_data_opt with
| None -> Format.fprintf fmt ""
| Some thread_data ->
Format.fprintf fmt
{|
<div class="thread-subject">
%s
</div>
|}
thread_data.subject
in
(* put a link in if its a preview *)
let link fmt () =
if Option.is_some thread_data_opt then
Format.fprintf fmt
{|<a class="stretched-link preview-link" href="/thread/%s"></a>|} id
in
Format.fprintf fmt
{|
<div class="position-relative post" id="%s">
%a
%a
%a
%a
<blockquote class="post-comment">%s</blockquote>
%a
</div>
|}
id pp_subject () link () post_info_view () image_view () comment tags_view
()
let view_post id =
let* post = get_post id in
Ok (Format.asprintf "%a" pp_post (Post post))
let pp_thread_preview fmt op =
let thread_data, post = op in
let thread_preview =
Format.fprintf fmt
{|
<div class="thread-preview">
%a
</div>
|}
pp_post
(Op (thread_data, post))
in
thread_preview
let catalog_content () =
let* ids = Q.get_threads () in
let* ops = get_ops ids in
Ok
(Format.asprintf "%a"
(Format.pp_print_list ~pp_sep:Format.pp_print_space pp_thread_preview)
ops )
let pp_report fmt post report request =
let url = "/admin" in
let _reporter_id, reporter_nick, reason, _date, id = report in
let input_post_id fmt id =
Format.fprintf fmt
{|<input value="%s" name="post_id" type="hidden"></input>|} id
in
let button fmt action =
let s = moderation_action_to_string action in
Format.fprintf fmt
{|<button value="%s" name="action" type="submit" class="btn btn-primary">%s</button>|}
s (String.uppercase_ascii s)
in
let form fmt action =
Format.fprintf fmt {|%s %a %a </form>|}
(Dream.form_tag ~action:url request)
input_post_id id button action
in
Format.fprintf fmt
{|
<div class="report">
<div class="row mb-3">
<div class="col-md-6">
%a
</div>
<div class="col-md-6">
<span> From: %s Reason: %s</span>
<div>
%a
</form><br>
%a
</form><br>
%a
</form><br>
</div>
</div>
</div>
</div><br>
|}
pp_post (Post post) reporter_nick reason form Ignore form Delete form Banish
let admin_page_content posts reports request =
let posts_reports = List.combine posts reports in
Format.asprintf "%a"
(Format.pp_print_list ~pp_sep:Format.pp_print_space
(fun fmt (post, report) -> pp_report fmt post report request) )
posts_reports
let pp_thread fmt op posts =
let thread_data, _post = op in
(*order by date *)
let posts = List.sort (fun a b -> compare a.date b.date) posts in
let posts_view fmt () =
Format.pp_print_list ~pp_sep:Format.pp_print_space
(fun fmt post -> pp_post fmt (Post post))
fmt posts
in
Format.fprintf fmt
{|
<div class="thread">
<div class="thread-subject">
<h1>%s</h1>
</div>
<div class="thread-posts">
%a
</div>
</div>
|}
thread_data.subject posts_view ()
let view_thread thread_id =
let* op = get_op thread_id in
let* ids = Q.get_thread_posts thread_id in
let* posts = get_posts ids in
let s =
(Format.asprintf "%a" (fun fmt (op, posts) -> pp_thread fmt op posts))
(op, posts)
in
Ok s
let pp_marker fmt op =
let thread_data, post = op in
let content = Format.asprintf "%a" pp_thread_preview op in
(* geojson use lng lat, and not lat lng*)
let json =
`Assoc
[ ("type", `String "Feature")
; ( "geometry"
, `Assoc
[ ("type", `String "Point")
; ( "coordinates"
, `List [ `Float thread_data.lng; `Float thread_data.lat ] )
] )
; ( "properties"
, `Assoc
[ ("content", `String content); ("thread_id", `String post.id) ] )
]
in
Yojson.pretty_print fmt json
let get_markers () =
let* ids = Q.get_threads () in
let* ops = get_ops ids in
let markers =
Format.asprintf "[%a]"
(Format.pp_print_list
~pp_sep:(fun fmt () -> Format.fprintf fmt ",")
pp_marker )
ops
in
Ok markers
let pp_checkboxes fmt () =
let pp_checkbox fmt category =
Format.fprintf fmt
{|
<div class="form-check col">
<input name="category" id="category-%s" type="checkbox" class"form-check-input" value="%s">
<label class="form-check-label" for="category-%s">%s</label>
</div>
|}
category category category category
in
Format.fprintf fmt
{|
<div class="row">
%a
</div>|}
(Format.pp_print_list ~pp_sep:Format.pp_print_space pp_checkbox)
App.categories
(* RFC-3339 date-time *)
let pp_date fmt date =
let date = Unix.gmtime date in
Format.fprintf fmt "%04d-%02d-%02dT%02d:%02d:%02dZ" (1900 + date.tm_year)
(1 + date.tm_mon) date.tm_mday date.tm_hour date.tm_min date.tm_sec
let pp_feed_entry fmt post =
Format.fprintf fmt
{|
<entry>
<title></title>
<id>urn:uuid:%s</id>
<updated>%a</updated>
<author>
<name>%s</name>
</author>
<content type="html">%s</content>
<link rel="alternate" href="%s/thread/%s#%s"/>
</entry>
|}
post.id pp_date post.date post.nick
(Dream.html_escape post.comment)
App.hostname post.parent_id post.id
let feed thread_id =
let* thread_data, op_post = get_op thread_id in
let* ids = Q.get_thread_posts thread_id in
let* posts = get_posts ids in
let posts = List.sort (fun a b -> compare b.date a.date) posts in
let* last_update =
match posts with [] -> Error "empty thread" | op :: _l -> Ok op.date
in
let entries fmt () =
(Format.pp_print_list ~pp_sep:Format.pp_print_space pp_feed_entry) fmt posts
in
let feed =
Format.asprintf
{|<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>%s</title>
<link rel="self" href="%s/thread/%s"/>
<updated>%a</updated>
<author>
<name>%s</name>
</author>
<id>urn:uuid:%s</id>
%a
</feed>|}
thread_data.subject App.hostname thread_id pp_date last_update
op_post.nick op_post.id entries ()
in
Ok feed

View file

@ -1,16 +0,0 @@
let f request =
<%s! Dream.form_tag ~action:"/register" request %>
<div class="mb-3">
<label for="nick" class="form-label">Nick</label>
<input name="nick" type="text" class="form-control" id="nick">
</div>
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input name="email" type="email" class="form-control" id="email">
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input name="password" type="password" class="form-control" id="password">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>

View file

@ -1,22 +0,0 @@
let f post_preview post_id request =
<script type="text/javascript" src="/assets/js/catalog.js" defer="defer"></script>
<%s! post_preview %>
% let url = Format.sprintf "/report/%s" post_id in
% begin match Dream.session "nick" request with
% | None ->
% let redirect = Dream.to_percent_encoded url in
<a href="/login?redirect=<%s redirect%>">Login</a> to report a post.
% | Some _nick ->
<div class="row mb-3">
<div class="col-md-6" id="report-form">
<div class="postForm">
<%s! Dream.form_tag ~action:url request %>
<label for="reason" id="reason-label" class="form-label">Reason:</label>
<input name="reason" id="reason" type="text" class="form-control" aria-labelledby="reason-label"></input>
<button type="submit" class="btn btn-primary">REPORT</button>
</form>
</div>
</div>
</div>
% end;

View file

@ -1,12 +1,73 @@
(* let bindings for early return when encountering an error *)
(* see https://ocaml.org/releases/4.13/htmlman/bindingops.html *)
let ( let* ) o f = match o with Ok v -> f v | Error _ as e -> e
let ( let* ) o f = Result.fold ~ok:f ~error:Result.error o
let ( let+ ) o f = match o with Ok v -> Ok (f v) | Error _ as e -> e
let unwrap_list f ids =
let l = List.map f ids in
let res = List.find_opt Result.is_error l in
match res with
| None -> Ok (List.map Result.get_ok l)
| Some (Ok _) -> assert false
| Some (Error _e as error) -> error
let ( let*! ) o f = match o with Ok v -> f v | Error _ as e -> Lwt.return e
let list_iter f l =
let err = ref None in
try
List.iter
(fun v ->
match f v with
| Error _e as e ->
err := Some e;
raise Exit
| Ok () -> () )
l;
Ok ()
with Exit -> ( match !err with None -> assert false | Some v -> v )
let list_map f l =
let err = ref None in
try
Ok
(List.map
(fun v ->
match f v with
| Error _e as e ->
err := Some e;
raise Exit
| Ok v -> v )
l )
with Exit -> ( match !err with None -> assert false | Some v -> v )
let list_fold_left f acc l =
List.fold_left
(fun acc v ->
let* acc = acc in
f acc v )
(Ok acc) l
let array_iter f a =
let err = ref None in
try
for i = 0 to Array.length a - 1 do
match f (Array.unsafe_get a i) with
| Error _e as e ->
err := Some e;
raise Exit
| Ok () -> ()
done;
Ok ()
with Exit -> ( match !err with None -> assert false | Some v -> v )
let array_map f a =
let err = ref None in
try
Ok
(Array.init (Array.length a) (fun i ->
let v = Array.get a i in
match f v with
| Error _e as e ->
err := Some e;
raise Exit
| Ok v -> v ) )
with Exit -> ( match !err with None -> assert false | Some v -> v )
let array_fold_left f acc l =
Array.fold_left
(fun acc v ->
let* acc = acc in
f acc v )
(Ok acc) l

View file

@ -1,84 +0,0 @@
let render_unsafe ~title ~content request =
<!DOCTYPE html>
<html lang="en">
<head>
<title><%s title %> | Permap</title>
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.png">
<link href="/assets/css/bootstrap.min.css" rel="stylesheet"/>
<link href="/assets/css/leaflet.css" rel="stylesheet">
<link href="/assets/css/style.css" rel="stylesheet">
</head>
<body>
<header>
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/"><img src="/assets/img/favicon.png" alt="Permap" height="22" /></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav me-auto mb-2 mb-md-0">
<li class="nav-item">
<a class="nav-link" href="/">🗺️ Babillard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/catalog">📑 Catalog</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/user">🫂 Discover users</a>
</li>
% begin if Option.is_some @@ Dream.session "nick" request then
<li class="nav-item">
<a class="nav-link" href="/discuss">📫 Discuss</a>
</li>
% end;
</ul>
<ul class="navbar-nav ms-auto mb-2 mb-md-0">
% begin match Dream.session "nick" request with
% | None ->
% begin if App.open_registration then
<li class="nav-item">
<a class="nav-link" href="/register">🍎 Register</a>
</li>
% end;
<li class="nav-item">
<a class="nav-link" href="/login">🚪 Login</a>
</li>
% | Some nick ->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" id="navbarDarkDropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-expanded="false">
🏡 <%s! nick %>
</a>
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end" aria-labelledby="navbarDarkDropdownMenuLink">
<li><a class="dropdown-item" href="/account">🧬 Account</a></li>
<li><a class="dropdown-item" href="/profile">🦩 Your profile</a></li>
% begin if List.mem nick App.admins then
<li><a class="dropdown-item" href="/admin">🪄 Administration</a></li>
% end;
<li><a class="dropdown-item" href="/logout">❌ Sign out</a></li>
</ul>
</li>
% end;
</ul>
</div>
</div>
</nav>
</header>
<br />
<br />
<br />
<br />
<main>
<div class="container">
<%s! content %>
</div>
<hr class="featurette-divider">
<footer class="container">
<p> <a href="/about">🤓 about permap</a>
| <a href="https://git.zapashcanon.fr/zapashcanon/permap">🛸 source code</a>
</p>
</footer>
</main>
<script src="/assets/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View file

@ -1,16 +0,0 @@
let f thread_view thread_id request =
<script type="text/javascript" src="/assets/js/thread.js" defer="defer"></script>
<%s! thread_view %>
% let thread_url = Format.sprintf "/thread/%s" thread_id in
% begin match Dream.session "nick" request with
% | None ->
% let redirect = Dream.to_percent_encoded thread_url in
<a href="/login?redirect=<%s redirect%>">Login to reply!</a>
% | Some _ ->
<%s! Post_form.f (Some thread_id) request %>
% end;
% let feed_url = Format.sprintf "%s/feed" thread_url in
<a type="application/atom+xml" href=<%s! feed_url %> >
<img src="/assets/img/atom.svg" class="rss-logo" />
</a>
<link rel="alternate" type="application/atom+xml" href=<%s! feed_url %> />

104
src/types.mli Normal file
View file

@ -0,0 +1,104 @@
(* shared server and client types *)
(* TODO
- rm not shared types
- type alias for better naming in other modules? *)
type post_id = int
type thread_id = int
type user_id = string
type v_string = Validate_str.v_string
type img_info =
{ md5 : string
; mime : string
; w : int
; h : int
; thumb_w : int
; thumb_h : int
; name : string
; alt : string
}
type img =
{ info : img_info
; data : string
; thumbnail_data : string
}
type comment = Comment.t
type bump_status =
| Dead
| Locked of int
| Alive of int
module User_private : sig
type t =
{ user_id : user_id
; user_nick : string
; user_is_admin : bool
; bio : string
; avatar_info : img_info option
; email : string
}
end
type user =
{ user_id : user_id
; user_nick : string
; user_is_admin : bool
; bio : string
; avatar_info : img_info option
}
type session =
{ user_private : User_private.t option
; csrf_token : string
; csrf_time_limit : float
}
type post =
{ id : post_id
; parent_t_id : thread_id
; date : float
; poster_id : user_id
; poster_nick : string
; comment : comment
; image_info : img_info option
; (* TODO get this out of this record? *)
backlinks : post_id list
}
(* TODO util conversion function for Thread_w_reply.t and user_private *)
module Thread_w_reply : sig
type t =
{ op : post
; subject : string
; lat : float
; lng : float
; bump_status : bump_status
; reply_count : int
; reply_l : post list
}
end
type thread =
{ op : post
; subject : string
; lat : float
; lng : float
; bump_status : bump_status
; reply_count : int
}
type report =
{ report_id : string
; report_date : float
; reported_post : post
; reporter_user_id : user_id
; reporter_nick : string
; reason : string
}

View file

@ -1,336 +1,115 @@
open Syntax
open Caqti_request.Infix
open Caqti_type
open Types
open Err
type t =
{ user_id : string
; nick : string
; password : string
; email : string
; bio : string
; metadata : (string * string) list
}
let check_nick nick =
let* nick = Validate_str.nick nick in
let* opt = Db_user.find_user_of_nick nick in
match opt with
| None -> Ok nick
| Some _user -> Error (Unprocessable "nick already taken")
let () =
let tables =
[| (unit ->. unit)
"CREATE TABLE IF NOT EXISTS user (user_id TEXT, nick TEXT, password \
TEXT, email TEXT, bio TEXT, PRIMARY KEY(user_id))"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS banished (nick TEXT, email TEXT)"
; (unit ->. unit)
"CREATE TABLE IF NOT EXISTS user_metadata (user_id TEXT, metadata \
TEXT, FOREIGN KEY(user_id) REFERENCES user(user_id) ON DELETE \
CASCADE)"
|]
in
if
Array.exists Result.is_error
(Array.map (fun query -> Db.exec query ()) tables)
then Dream.error (fun log -> log "can't create user tables")
module Q = struct
let upload_metadata =
Db.exec
@@ (tup2 string string ->. unit) "INSERT INTO user_metadata VALUES (?, ?)"
let delete_metadata =
Db.exec @@ (string ->. unit) "DELETE FROM user_metadata WHERE user_id=?"
let get_user_id_from_email =
Db.find @@ (string ->! string) "SELECT user_id FROM user WHERE email=?"
let get_password =
Db.find @@ (string ->! string) "SELECT password FROM user WHERE user_id=?"
let is_already_user =
Db.find
@@ (tup2 string string ->! int)
"SELECT EXISTS(SELECT 1 FROM user WHERE nick=? OR email=?)"
let upload_user =
Db.exec
@@ (tup4 string string string (tup2 string string) ->. unit)
"INSERT INTO user VALUES (?, ?, ?, ?, ?)"
let list_nicks = Db.collect_list @@ (unit ->* string) "SELECT nick FROM user"
let get_user =
Db.find
@@ (* there is no "tup6" *)
(string ->! tup4 string string string (tup2 string string))
"SELECT * FROM user WHERE user_id=?"
let update_bio =
Db.exec
@@ (tup2 string string ->. unit) "UPDATE user SET bio=? WHERE user_id=?"
let update_nick =
Db.exec
@@ (tup2 string string ->. unit) "UPDATE user SET nick=? WHERE user_id=?"
let update_email =
Db.exec
@@ (tup2 string string ->. unit) "UPDATE user SET email=? WHERE user_id=?"
let update_password =
Db.exec
@@ (tup2 string string ->. unit)
"UPDATE user SET password=? WHERE user_id=?"
let get_bio =
Db.find @@ (string ->! string) "SELECT bio FROM user WHERE user_id=?"
let get_email =
Db.find @@ (string ->! string) "SELECT email FROM user WHERE user_id=?"
let delete_user =
Db.exec @@ (string ->. unit) "DELETE FROM user WHERE user_id=?"
let upload_banished =
Db.exec @@ (tup2 string string ->. unit) "INSERT INTO banished VALUES (?,?)"
let get_banished =
Db.find
@@ (tup2 string string ->! tup2 string string)
"SELECT * FROM banished WHERE nick=? OR email=?"
end
let get_nick =
Db.find @@ (string ->! string) "SELECT nick FROM user WHERE user_id=?"
let get_id_from_nick =
Db.find @@ (string ->! string) "SELECT user_id FROM user WHERE nick=?"
let exist id = Result.is_ok (Q.get_user id)
let exist_nick nick = Result.is_ok (get_id_from_nick nick)
let exist_email email = Result.is_ok (Q.get_user_id_from_email email)
let get_metadata =
let query =
Db.find
@@ (string ->! string) "SELECT metadata FROM user_metadata WHERE user_id=?"
in
fun nick ->
let* metadata = query nick in
let metadata : (string * string) list = Marshal.from_string metadata 0 in
Ok metadata
let check_email email =
let* email = Validate_str.email email in
match Emile.of_string (email :> string) with
| Error _ -> Error (Unprocessable "invalid email format")
| Ok _ -> (
let* opt = Db_user.find_user_of_email email in
match opt with
| None -> Ok email
| Some _user -> Error (Unprocessable "email already taken") )
let get_user user_id =
let* user_id, nick, password, (email, bio) = Q.get_user user_id in
let* metadata = get_metadata user_id in
Ok { user_id; nick; password; email; bio; metadata }
let* opt = Db_user.find_user user_id in
match opt with None -> Error (Not_found_user user_id) | Some o -> Ok o
let is_banished login = Result.is_ok (Q.get_banished (login, login))
let get_user_private user_id =
let* opt = Db_user.find_user_private user_id in
match opt with None -> Error (Not_found_user user_id) | Some o -> Ok o
let login ~login ~password request =
let try_password user_id =
let* good_password = Q.get_password user_id in
(* login can be nick or email *)
let login ~login ~password =
let f find s =
let* opt = find s in
match opt with
| None -> Ok None
| Some user ->
let* good_password = Db_user.get_password_hash user.user_id in
if Bcrypt.verify password (Bcrypt.hash_of_string good_password) then
let _unit_lwt = Dream.invalidate_session request in
let _unit_lwt = Dream.put_session "user_id" user_id request in
let* nick = get_nick user_id in
let _unit_lwt = Dream.put_session "nick" nick request in
Ok ()
else if is_banished login then Error "YOU ARE BANISHED"
else Error "wrong password"
let* opt = Db_user.find_user_private user.user_id in
match opt with
| None ->
Error
(Internal (Db_not_found (Fmt.str "user_private `%s`" user.user_id)))
| Some o -> Ok (Some o)
else Error (Unauthorized_login login)
in
let id_from_nick = get_id_from_nick login in
let id_from_email = Q.get_user_id_from_email login in
let user_id_list =
List.filter_map Result.to_option [ id_from_nick; id_from_email ]
(* assume login is nick *)
let* opt =
match Validate_str.nick login with
| Error _ -> Ok None
| Ok nick -> f Db_user.find_user_of_nick nick
in
try
List.iter
(fun id -> if Result.is_ok @@ try_password id then raise Exit)
user_id_list;
Error "invalid login"
with Exit -> Ok ()
let valid_nick nick =
String.length nick < 64
&& String.length nick > 0
&& Dream.html_escape nick = nick
let valid_password password =
String.length password < 128 && String.length password > 0
let valid_email email = Result.is_ok @@ Emile.of_string email
match opt with
| Some u -> Ok u
| None -> (
(* assume login is email *)
let* email = Validate_str.email login in
let* opt = f Db_user.find_user_of_email email in
match opt with Some u -> Ok u | None -> Error (Unauthorized_login login) )
let register ~email ~nick ~password =
let valid = valid_nick nick && valid_email email && valid_password password in
let password = Bcrypt.hash password in
let password = Bcrypt.string_of_hash password in
if not valid then Error "Something is wrong"
else
let* nb = Q.is_already_user (nick, email) in
if nb = 0 then
let user_id = Uuidm.to_string (Uuidm.v4_gen App.random_state ()) in
let* () = Q.upload_user (user_id, nick, password, (email, "")) in
Q.upload_metadata (user_id, Marshal.to_string [] [])
else Error "nick or email already exists"
let list () =
let* users = Q.list_nicks () in
Ok
(Format.asprintf "<ul>%a</ul>"
(Format.pp_print_list (fun fmt -> function
| s -> Format.fprintf fmt {|<li><a href="/user/%s">%s</a></li>|} s s )
)
users )
let profile request =
match Dream.session "nick" request with
| None -> "not logged in"
| Some nick -> Format.sprintf "Hello %s !" nick
let update_bio bio user_id =
let bio = Dream.html_escape bio in
let valid = String.length bio < 10000 in
if not valid then Error "Not biologic" else Q.update_bio (bio, user_id)
let upload_avatar files user_id =
match files with
| [] -> Error "No file provided"
| [ (name_opt, content) ] ->
let* image = Image.make_image (name_opt, "avatar", content) in
let* () = Image.upload_avatar image user_id in
Ok ()
| _files -> Error "More than one file provided"
let is_admin user_id =
match get_nick user_id with
| Error _e -> false
| Ok nick -> List.mem nick App.admins
let banish user_id =
let* nick = get_nick user_id in
let* email = Q.get_email user_id in
let* () = Q.delete_user user_id in
Q.upload_banished (nick, email)
let delete_user user_id = Q.delete_user user_id
let update_nick nick user_id =
if valid_nick nick then
if not (exist_nick nick) then Q.update_nick (nick, user_id)
else Error "nick already taken"
else Error "invalid nick"
let update_email email user_id =
if valid_email email then
if not (exist_email email) then Q.update_email (email, user_id)
else Error "email already taken"
else Error "invalid email"
let update_password password user_id =
if valid_password password then
let password = Bcrypt.hash password |> Bcrypt.string_of_hash in
Q.update_password (password, user_id)
else Error "invalid password"
let update_metadata count label content user_id =
let label = Dream.html_escape label in
let content = Dream.html_escape content in
if String.length label > 200 || String.length content > 400 then
Error "label or content is too long"
else
let* metadata = get_metadata user_id in
let length = List.length metadata in
if count < 0 || count > length then Error "invalid count"
else
let n = max (count + 1) @@ length in
let metadata = Array.of_list metadata in
let metadata =
List.init n (fun i ->
if i = count then (label, content) else metadata.(i) )
let* password = Validate_str.password password in
let* nick = check_nick nick in
let* email = check_email email in
let* () =
let* opt1 = Db_user.find_user_of_nick nick in
let* opt2 = Db_user.find_user_of_email email in
let loggin_not_taken = Option.is_none opt1 && Option.is_none opt2 in
if loggin_not_taken then Ok ()
else Error (Unprocessable "nick or email already taken")
in
let metadata =
List.filter (fun (l, c) -> not (l = "" && c = "")) metadata
let password_hash =
Bcrypt.hash (password :> string) |> Bcrypt.string_of_hash
in
if List.length metadata >= 42 then Error "to many metadata"
else
let s = Marshal.to_string metadata [] in
let* () = Q.delete_metadata user_id in
Q.upload_metadata (user_id, s)
Db_user.add_user ~email ~nick ~password_hash
let pp_metadata fmt (label, content) =
Format.fprintf fmt
{|
<div class="row">
<div class="col metadata-label">%s</div>
<div class="col metadata-content">%s</div>
</div>
|}
label content
let delete_user user_id = Db_user.delete_user user_id
let pp_metadata_form fmt is_last count (label, content) request =
let form_tag = Dream.form_tag ~action:"/profile" request in
let button_text = if is_last then "Add" else "Save" in
Format.fprintf fmt
{|
<div class="row">
%s
<input name="label" class="metadata-label" value="%s"/>
<input name="content" class="metadata-content" value="%s"/>
<button name="count" value="%d" type="submit" class="btn btn-primary">%s</button>
</form>
</div>
|}
form_tag label content count button_text
let update_bio user_id bio =
let* bio = Validate_str.bio bio in
Db_user.update_bio user_id bio
let pp_metadata_table fmt metadata =
Format.fprintf fmt
{|
<div class="metadata-table">
%a
</div>
|}
(Format.pp_print_list ~pp_sep:Format.pp_print_space pp_metadata)
metadata
let update_nick user_id nick =
let* nick = check_nick nick in
Db_user.update_nick user_id nick
let pp_metadata_table_form fmt metadata request =
let l = List.mapi (fun i e -> (i, e)) metadata in
Format.fprintf fmt
{|
<div class="metadata-form-table">
%a
%a
</div>
|}
(Format.pp_print_list ~pp_sep:Format.pp_print_space
(fun fmt (count, metadata) ->
pp_metadata_form fmt false count metadata request ) )
l
(fun fmt (count, metadata) ->
pp_metadata_form fmt true count metadata request )
(List.length l, ("", ""))
let update_email user_id email =
let* email = check_email email in
Db_user.update_email user_id email
let public_profile user_id =
let* user = get_user user_id in
let user_info =
Format.asprintf
{|
<h1>%s</h1>
<br />
<div class="row">
<div class="col-md-6">
<blockquote>%s</blockquote>
</div>
<div class="col-md-6">
<img src="/user/%s/avatar" class="img-thumbnail" alt="Your avatar picture">
</div>
<a href="/discuss/%s">Speak to me !</a>
<div class="col-md-6">
%a
</div>
</div>
|}
user.nick user.bio user.nick user_id pp_metadata_table user.metadata
let update_password user_id password =
let* password = Validate_str.password password in
let password_hash =
Bcrypt.hash (password :> string) |> Bcrypt.string_of_hash
in
Ok user_info
Db_user.update_password_hash user_id password_hash
let get_image user_id =
let default_avatar_path = "/img/default_avatar.png" in
let* opt = Db_image.U.data user_id in
match opt with
| Some data -> Ok data
| None -> (
match Assets.read default_avatar_path with
| None -> Error (Internal (Db_not_found "can not find default avatar file"))
| Some avatar -> Ok avatar )
(* TODO sql : rm image db functor, handle avatar image and transaction in db_user *)
let upload_avatar user_id (name_opt, alt, content) =
let name = Option.value ~default:"" name_opt in
let* image = Image.build ~name ~alt content in
Caqti_db.Db.do_transaction @@ fun () -> Db_image.U.upload user_id image
let delete_avatar user_id =
Caqti_db.Db.do_transaction @@ fun () -> Db_image.U.delete user_id

View file

@ -1,28 +0,0 @@
let f (user: User.t) request =
<h1><%s Format.sprintf "Account settings" %></h1>
<h2>Change email</h2>
<%s! Dream.form_tag ~action:"/account" request %>
<div class="mb-3">
<label for="email" class="form-label">Email:</label>
<input name="email" type="text" class="form-control" id="email" value="<%s! user.email %>"></input>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
<br />
<br />
<h2>Change password</h2>
<%s! Dream.form_tag ~action:"/account" request %>
<div class="mb-3">
<label for="new-password" class="form-label">New password:</label>
<input name="new-password" type="password" class="form-control" id="new-password"></input>
<label for="confirm-new-password" class="form-label">Confirm new password:</label>
<input name="confirm-new-password" type="password" class="form-control" id="confirm-new-password"></input>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
<br />
<br />
<h2>Delete account</h2>
<%s! Dream.form_tag ~action:"/account" request %>
<button name="delete" id="delete-button" type="submit" class="btn btn-danger">DELETE ACCOUNT</button>
</form>

View file

@ -1,40 +0,0 @@
let f (user: User.t) request =
% let metadata_table = Format.asprintf "%a" (fun fmt metadata -> User.pp_metadata_table_form fmt metadata request) user.metadata in
<h1>Edit your profile</h1>
<p>Check your <a href="/user/<%s user.nick %>">public profile rendering</a>.</p>
<h2>Display nickname</h2>
<%s! Dream.form_tag ~action:"/profile" request %>
<div class="mb-3">
<label for="nick" class="form-label">Change display name</label>
<input name="nick" type="text" class="form-control" id="nick" value="<%s! user.nick %>"></input>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
<br />
<br />
<h2>Profile metadata</h2>
<p>Add items displayed as a table on your profile.</p>
<%s! metadata_table %>
<br />
<br />
<h2>Bio</h2>
<%s! Dream.form_tag ~action:"/profile" request %>
<div class="mb-3">
<label for="bio" class="form-label">Change your bio</label>
<textarea name="bio" type="text" class="form-control" id="bio" aria-describedby="bio-help"><%s! user.bio %></textarea>
<div id="bio-help" class="form-text">Who are you?</div>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
<br />
<h2>Avatar</h2>
<img src="/user/<%s user.nick %>/avatar" class="img-thumbnail" alt="Your avatar picture" />
<br />
<br />
<%s! Dream.form_tag ~action:"/profile" ~enctype:`Multipart_form_data request %>
<br />
<label for="file" id="file-label" class="form-label">Change avatar</label>
<input id="file" class="form-control" name="file" aria-describedby="file-label" type="file" accept="image/png,image/jpeg,image/webp,image/gif">
<br />
<button class="btn btn-primary">Submit avatar!</button>
</form>

Some files were not shown because too many files have changed in this diff Show more