commit 96856ea7c90a846cfa66bfb2f7937a94f38dd047 Author: Erika Rowland Date: Sat Apr 13 21:28:08 2024 -0700 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ef61a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.beam +*.ez +/build +erl_crash.dump +.envrc diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..c068776 --- /dev/null +++ b/gleam.toml @@ -0,0 +1,25 @@ +name = "bowie" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "username", repo = "project" } +# links = [{ title = "Website", href = "https://gleam.run" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = "~> 0.34 or ~> 1.0" +nakai = "~> 0.9" +gleam_erlang = "~> 0.25" +wisp = "~> 0.14" +mist = "~> 1.0" +envoy = "~> 1.0" +sqlight = "~> 0.9" + +[dev-dependencies] +gleeunit = "~> 1.0" diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..ec911d6 --- /dev/null +++ b/manifest.toml @@ -0,0 +1,38 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "birl", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "976CFF85D34D50F7775896615A71745FBE0C325E50399787088F941B539A0497" }, + { name = "envoy", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "CFAACCCFC47654F7E8B75E614746ED924C65BD08B1DE21101548AC314A8B6A41" }, + { name = "esqlite", version = "0.8.7", build_tools = ["rebar3"], requirements = [], otp_app = "esqlite", source = "hex", outer_checksum = "AF89C66027704B681657FDCCFFCEAA238D0DD702D5F687CDA3037F1D599A7551" }, + { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, + { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, + { name = "gleam_json", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "8B197DD5D578EA6AC2C0D4BDC634C71A5BCA8E7DB5F47091C263ECB411A60DF3" }, + { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, + { name = "gleam_stdlib", version = "0.36.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "C0D14D807FEC6F8A08A7C9EF8DFDE6AE5C10E40E21325B2B29365965D82EB3D4" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "82C112ED9B6C30C1772A6FE2613B94B13F62EA35F5869A2630D13948D297BD39" }, + { name = "marceau", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "1AAD727A30BE0F95562C3403BB9B27C823797AD90037714255EEBF617B1CDA81" }, + { name = "mist", version = "1.0.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7765E53DCC9ACCACF217B8E0CA3DE7E848C783BFAE5118B75011E81C2C80385C" }, + { name = "nakai", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "nakai", source = "hex", outer_checksum = "F6FFED9EF4B0E14C7A09B2FB87B42D3A93EFE024FD0299C11F041E92321163A6" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, + { name = "sqlight", version = "0.9.0", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "2D9C9BA420A5E7DCE7DB2DAAE4CAB0BE6218BEB48FD1531C583550B3D1316E94" }, + { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, + { name = "wisp", version = "0.14.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "9F5453AF1F9275E6F8707BC815D6A6A9DF41551921B16FBDBA52883773BAE684" }, +] + +[requirements] +envoy = { version = "~> 1.0" } +gleam_erlang = { version = "~> 0.25" } +gleam_stdlib = { version = "~> 0.34 or ~> 1.0" } +gleeunit = { version = "~> 1.0" } +mist = { version = "~> 1.0" } +nakai = { version = "~> 0.9" } +sqlight = { version = "~> 0.9"} +wisp = { version = "~> 0.14" } diff --git a/src/bowie.gleam b/src/bowie.gleam new file mode 100644 index 0000000..ea7c41d --- /dev/null +++ b/src/bowie.gleam @@ -0,0 +1,41 @@ +import gleam/erlang/process +import mist +import wisp +import bowie/router +import bowie/web.{Context, Env} +import envoy +import sqlight + +pub fn main() { + wisp.configure_logger() + let secret_key_base = wisp.random_string(64) + + let assert Ok(admin_token) = envoy.get("ADMIN_TOKEN") + let assert Ok(annual_link) = envoy.get("ANNUAL_LINK") + let assert Ok(monthly_link) = envoy.get("MONTHLY_LINK") + let assert Ok(forgejo_url) = envoy.get("FORGEJO_URL") + let assert Ok(stripe_token) = envoy.get("STRIPE_TOKEN") + let assert Ok(database_file) = envoy.get("DATABASE_FILE") + + use db <- sqlight.with_connection(database_file) + + let context = + Context( + db: db, + env: Env( + annual_link: annual_link, + monthly_link: monthly_link, + forgejo_url: forgejo_url, + stripe_token: stripe_token, + admin_token: admin_token, + ), + ) + + let assert Ok(_) = + wisp.mist_handler(router.handle_request(_, context), secret_key_base) + |> mist.new + |> mist.port(8000) + |> mist.start_http + + process.sleep_forever() +} diff --git a/src/bowie/router.gleam b/src/bowie/router.gleam new file mode 100644 index 0000000..f61981b --- /dev/null +++ b/src/bowie/router.gleam @@ -0,0 +1,46 @@ +import wisp.{type Request, type Response} +import gleam/http.{Get} +import bowie/web.{type Context} +import sqlight +import bowie/templates/signup +import gleam/option.{None, Some} + +pub fn handle_request(req: Request, context: Context) -> Response { + use req <- web.middleware(req) + + case wisp.path_segments(req) { + ["signup"] -> + case req.method { + http.Get -> get_signup(req, context) + http.Post -> post_signup(req) + _ -> wisp.method_not_allowed(allowed: [http.Get, http.Post]) + } + + ["payment_success", receipt] -> payment_success(req, receipt) + + _ -> wisp.not_found() + } +} + +fn get_signup(req, context: Context) { + use <- wisp.require_method(req, Get) + + wisp.ok() + |> wisp.html_body(signup.render( + monthly_link: Some(context.env.monthly_link), + annual_link: Some(context.env.annual_link), + invitation: None, + )) +} + +fn post_signup(req) { + use <- wisp.require_method(req, http.Post) + + wisp.ok() +} + +fn payment_success(req, receipt) { + use <- wisp.require_method(req, http.Post) + + wisp.ok() +} diff --git a/src/bowie/templates/base.gleam b/src/bowie/templates/base.gleam new file mode 100644 index 0000000..2a100cb --- /dev/null +++ b/src/bowie/templates/base.gleam @@ -0,0 +1,23 @@ +import nakai +import nakai/html +import nakai/html/attrs + +pub fn render(title title, content content) { + html.div([attrs.id("content")], [ + html.Html([attrs.lang("en-US")], []), + html.Head([ + html.title(title), + html.meta([ + attrs.name("viewport"), + attrs.content("width=device-width, initial-scale=1"), + ]), + html.link([attrs.rel("stylesheet"), attrs.href("/assets/css/index.css")]), + html.link([ + attrs.rel("stylesheet"), + attrs.href("/assets/css/theme-forgejo-auto.css"), + ]), + ]), + ..content + ]) + |> nakai.to_string_builder() +} diff --git a/src/bowie/templates/signup.gleam b/src/bowie/templates/signup.gleam new file mode 100644 index 0000000..cdbb39c --- /dev/null +++ b/src/bowie/templates/signup.gleam @@ -0,0 +1,40 @@ +import nakai/html +import nakai/html/attrs +import bowie/templates/base +import gleam/option.{type Option, None, Some} + +pub fn render( + monthly_link monthly_link: Option(String), + annual_link annual_link: Option(String), + invitation invitation: Option(String), +) { + let title = "Welcome to the Kitten Collective!" + + base.render( + title: title, + content: content(monthly_link, annual_link, invitation), + ) +} + +fn content( + monthly_link: Option(String), + annual_link: Option(String), + invitation: Option(String), +) { + let monthly = format_link(monthly_link, "Just $3/month!") + let annual = format_link(annual_link, "Just $30/year!") + let invite = + format_link( + invitation, + "Free, limited account for collaborating with other kittens", + ) + [monthly, annual, invite] +} + +fn format_link(link, text) { + case link { + None -> html.Nothing + Some(link) -> + html.div([], [html.p([], [html.a_text([attrs.href(link)], text)])]) + } +} diff --git a/src/bowie/templates/user_form.gleam b/src/bowie/templates/user_form.gleam new file mode 100644 index 0000000..c67623a --- /dev/null +++ b/src/bowie/templates/user_form.gleam @@ -0,0 +1,150 @@ +import nakai/html +import nakai/html/attrs +import bowie/templates/base + +pub fn render(receipt) { + let title = "Welcome, friend, to git.kittencollective.com" + base.render(title: title, content: content(receipt)) +} + +fn content(receipt) { + [ + html.div( + [ + attrs.Attr(name: "role", value: "main"), + attrs.Attr(name: "aria-label", value: "Sign In"), + attrs.class("page-content user signin"), + ], + [ + html.div([attrs.class("ui middle very relaxed page grid")], [ + html.div([attrs.class("ui container column fluid")], [ + html.h4_text( + [attrs.class("ui top attached header center")], + "Sign Up", + ), + html.div([attrs.class("ui attached segment")], [ + html.form( + [ + attrs.class("ui form"), + attrs.action("/signup"), + attrs.Attr( + name: "enctype", + value: "application/x-www-form-urlencoded", + ), + attrs.method("post"), + ], + [ + html.input([ + attrs.type_("hidden"), + attrs.value(receipt), + attrs.name("receipt"), + ]), + required_field( + for: "username", + text: "Username", + input_type: "text", + id: "username", + extra_attributes: [ + attrs.Attr(name: "minlength", value: "1"), + attrs.Attr(name: "maxlength", value: "20"), + ], + ), + optional_field( + for: "displayname", + text: "Displayname (Optional)", + input_type: "text", + id: "displayname", + extra_attributes: [], + ), + required_field( + for: "email", + text: "Email", + input_type: "text", + id: "email", + extra_attributes: [], + ), + required_field( + for: "password", + text: "Password", + input_type: "password", + id: "password", + extra_attributes: [], + ), + required_field( + for: "confirm_password", + text: "Confirm Password", + input_type: "password", + id: "pw_verify", + extra_attributes: [], + ), + html.div([attrs.class("inline field")], [ + html.label([], []), + html.button_text( + [attrs.class("ui primary button")], + "Sign Up", + ), + ]), + ], + ), + ]), + ]), + ]), + ], + ), + ] +} + +fn optional_field( + for label_for, + text text, + input_type input_type, + id id, + extra_attributes extra_attributes, +) { + field( + for: label_for, + text: text, + input_type: input_type, + id: id, + extra_attributes: extra_attributes, + ) +} + +fn required_field( + for label_for, + text text, + input_type input_type, + id id, + extra_attributes extra_attributes, +) { + field( + for: label_for, + text: text, + input_type: input_type, + id: id, + extra_attributes: [ + attrs.Attr(name: "required", value: ""), + ..extra_attributes + ], + ) +} + +fn field( + for label_for, + text text, + input_type input_type, + id id, + extra_attributes extra_attributes, +) { + html.div([attrs.class("required inline field ")], [ + html.label_text([attrs.for(label_for)], text), + html.input([ + attrs.type_(input_type), + attrs.name(id), + attrs.id(id), + attrs.Attr(name: "required", value: ""), + ..extra_attributes + ]), + html.br([]), + ]) +} diff --git a/src/bowie/web.gleam b/src/bowie/web.gleam new file mode 100644 index 0000000..e343b81 --- /dev/null +++ b/src/bowie/web.gleam @@ -0,0 +1,28 @@ +import wisp +import sqlight.{type Connection} + +pub type Context { + Context(env: Env, db: Connection) +} + +pub type Env { + Env( + annual_link: String, + forgejo_url: String, + stripe_token: String, + monthly_link: String, + admin_token: String, + ) +} + +pub fn middleware( + req: wisp.Request, + handle_request: fn(wisp.Request) -> wisp.Response, +) -> wisp.Response { + let req = wisp.method_override(req) + use <- wisp.log_request(req) + use <- wisp.rescue_crashes + use req <- wisp.handle_head(req) + + handle_request(req) +} diff --git a/user_invitations.sql b/user_invitations.sql new file mode 100644 index 0000000..f8c9f77 --- /dev/null +++ b/user_invitations.sql @@ -0,0 +1,42 @@ +create table if not exists customers ( + id integer primary key, + username text not null unique, + receipt text not null unique, + billing_email text, + invitation id, + created_at int not null default (unixepoch()), + updated_at int not null default (unixepoch()), + foreign key (invitation) references invitations (id) +); +create index if not exists customers_username_dex on customers (lower(username)); +create index if not exists customers_email_dex on customers (lower(billing_email)); +create index if not exists customers_receipt_dex on customers (receipt); +create index if not exists customers_invitation_dex on customers (invitation); -- does this need to be created? it's already a foreign key + +create trigger if not exists update_customers_updated_at + after update on customers + when OLD.updated_at = NEW.updated_at or OLD.updated_at is null +BEGIN + update customers set updated_at = (select unixepoch()) where id=NEW.id; +END; + + +create table if not exists invitations ( + id integer primary key, + owner integer not null, + remaining integer not null default 1, + expires_at integer, + created_at integer not null default (unixepoch()), + updated_at integer not null default (unixepoch()), + foreign key (owner) references customers (id) +); +create index if not exists invitations_owner_dex on invitations (owner); + +create trigger if not exists update_invitations_updated_at + after update on invitations + when OLD.updated_at = NEW.updated_at or OLD.updated_at is null +BEGIN + update invitations set updated_at = (select unixepoch()) where id=NEW.id; +END; + +