diff --git a/gleam.toml b/gleam.toml index 5e3548f..b41f1f8 100644 --- a/gleam.toml +++ b/gleam.toml @@ -20,7 +20,7 @@ gleam_pgo = ">= 0.13.0 and < 1.0.0" birl = ">= 1.7.1 and < 2.0.0" decode = ">= 0.2.0 and < 1.0.0" gleam_json = ">= 1.0.1 and < 2.0.0" -youid = ">= 1.2.0 and < 2.0.0" +gleam_crypto = ">= 1.3.0 and < 2.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index cc58daf..3b0b281 100644 --- a/manifest.toml +++ b/manifest.toml @@ -32,16 +32,15 @@ packages = [ { name = "startest", version = "0.5.0", build_tools = ["gleam"], requirements = ["argv", "bigben", "birl", "exception", "gleam_community_ansi", "gleam_erlang", "gleam_javascript", "gleam_stdlib", "glint", "simplifile", "tom"], otp_app = "startest", source = "hex", outer_checksum = "7A4BE0A1674D1DEB421B0BEB1E90B1A9C4AB21D954CF1754C0E28EA6B5DBE785" }, { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, - { name = "youid", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_stdlib"], otp_app = "youid", source = "hex", outer_checksum = "EF0F693004E221155EE5909C6D3C945DD14F7117DBA882887CF5F45BE399B8CA" }, ] [requirements] argus = { version = ">= 1.0.0 and < 2.0.0" } birl = { version = ">= 1.7.1 and < 2.0.0" } decode = { version = ">= 0.2.0 and < 1.0.0" } +gleam_crypto = { version = ">= 1.3.0 and < 2.0.0" } gleam_erlang = { version = ">= 0.25.0 and < 1.0.0" } gleam_json = { version = ">= 1.0.1 and < 2.0.0" } gleam_pgo = { version = ">= 0.13.0 and < 1.0.0" } gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } -youid = { version = ">= 1.2.0 and < 2.0.0" } diff --git a/src/pevensie/auth.gleam b/src/pevensie/auth.gleam index 897e6cc..c7a4007 100644 --- a/src/pevensie/auth.gleam +++ b/src/pevensie/auth.gleam @@ -1,24 +1,25 @@ import argus -import birl.{type Time} -import birl/duration +import gleam/bit_array +import gleam/crypto.{Sha256} import gleam/dict import gleam/dynamic.{type Decoder} -import gleam/json +import gleam/io import gleam/option.{type Option, None, Some} import gleam/result -import pevensie/cache +import gleam/string import pevensie/drivers.{ type AuthDriver, type Connected, type Disabled, type Disconnected, } import pevensie/internal/auth import pevensie/internal/encoder.{type Encoder} import pevensie/internal/pevensie.{type Pevensie} +import pevensie/internal/session.{type Session} import pevensie/internal/user.{ type User as InternalUser, type UserInsert as UserInsertInternal, type UserUpdate as UserUpdateInternal, Set, UserInsert, UserUpdate, default_user_update, } -import youid/uuid +import pevensie/net.{type IpAddress} pub type User(user_metadata) = InternalUser(user_metadata) @@ -36,8 +37,14 @@ pub fn new_auth_config( driver driver: AuthDriver(driver, user_metadata), user_metadata_decoder user_metadata_decoder: Decoder(user_metadata), user_metadata_encoder user_metadata_encoder: Encoder(user_metadata), + cookie_key cookie_key: String, ) -> AuthConfig(driver, user_metadata, Disconnected) { - auth.AuthConfig(driver, user_metadata_decoder, user_metadata_encoder) + auth.AuthConfig( + driver:, + user_metadata_decoder:, + user_metadata_encoder:, + cookie_key:, + ) } pub fn disabled() -> AuthConfig(Nil, user_metadata, Disabled) { @@ -120,9 +127,10 @@ pub fn create_user_with_email( user_metadata: user_metadata, ) -> Result(User(user_metadata), Nil) { let assert auth.AuthConfig( - driver, - user_metadata_decoder, - user_metadata_encoder, + driver:, + user_metadata_decoder:, + user_metadata_encoder:, + .., ) = pevensie.auth_config // TODO: Handle errors @@ -161,6 +169,7 @@ pub fn set_user_role( driver:, user_metadata_decoder:, user_metadata_encoder:, + .., ) = pevensie.auth_config driver.update_user( @@ -173,72 +182,29 @@ pub fn set_user_role( ) } -pub type Session { - Session(id: String, user_id: String, created_at: Time, expires_at: Time) -} - -fn encode_session(session: Session) -> json.Json { - json.object([ - #("id", json.string(session.id)), - #("user_id", json.string(session.user_id)), - #("created_at", json.string(birl.to_iso8601(session.created_at))), - #("expires_at", json.string(birl.to_iso8601(session.expires_at))), - ]) -} - -fn session_decoder() -> Decoder(Session) { - let time_decoder = fn(time) { - use time <- result.try(dynamic.string(time)) - birl.parse(time) - |> result.replace_error([]) - } - - dynamic.decode4( - Session, - dynamic.field("id", dynamic.string), - dynamic.field("user_id", dynamic.string), - dynamic.field("created_at", time_decoder), - dynamic.field("expires_at", time_decoder), - ) -} - -const session_resource_type = "pevensie:session" - pub fn create_session( pevensie: Pevensie( user_metadata, auth_driver, Connected, cache_driver, - Connected, + cache_status, ), user_id: String, + ip: Option(IpAddress), + user_agent: Option(String), ttl_seconds: Option(Int), ) -> Result(Session, Nil) { - // Check if the user exists - use user <- result.try(get_user_by_id(pevensie, user_id)) - - let ttl_seconds = option.unwrap(ttl_seconds, 24 * 60 * 60) - let now = birl.now() - let session = - Session( - id: uuid.v7_string(), - user_id: user.id, - created_at: now, - expires_at: birl.add(now, duration.seconds(ttl_seconds)), - ) + let assert auth.AuthConfig(driver, ..) = pevensie.auth_config - use _ <- result.try(cache.store( - pevensie, - session_resource_type, - session.id, - session - |> encode_session - |> json.to_string, - Some(ttl_seconds), - )) - - Ok(session) + driver.create_session( + driver.driver, + user_id, + ip, + user_agent, + ttl_seconds, + False, + ) } pub fn get_session( @@ -247,25 +213,13 @@ pub fn get_session( auth_driver, Connected, cache_driver, - Connected, + cache_status, ), session_id: String, ) -> Result(Option(Session), Nil) { - use session <- result.try(cache.get( - pevensie, - session_resource_type, - session_id, - )) - case session { - None -> Ok(None) - Some(session_string) -> { - use decoded_session <- result.try( - json.decode(session_string, session_decoder()) - |> result.replace_error(Nil), - ) - Ok(Some(decoded_session)) - } - } + let assert auth.AuthConfig(driver, ..) = pevensie.auth_config + + driver.get_session(driver.driver, session_id, None, None) } pub fn delete_session( @@ -278,8 +232,9 @@ pub fn delete_session( ), session_id: String, ) -> Result(Nil, Nil) { - use _ <- result.try(cache.delete(pevensie, session_resource_type, session_id)) - Ok(Nil) + let assert auth.AuthConfig(driver, ..) = pevensie.auth_config + + driver.delete_session(driver.driver, session_id) } pub fn log_in_user( @@ -292,12 +247,62 @@ pub fn log_in_user( ), email: String, password: String, + ip: Option(IpAddress), + user_agent: Option(String), ) -> Result(#(Session, User(user_metadata)), Nil) { use user <- result.try(get_user_by_email_and_password( pevensie, email, password, )) - create_session(pevensie, user.id, Some(24 * 60 * 60)) + create_session(pevensie, user.id, ip, user_agent, Some(24 * 60 * 60)) |> result.map(fn(session) { #(session, user) }) } + +fn sha256_hash(data: String, key: String) -> Result(String, Nil) { + let key = bit_array.from_string(key) + let data = bit_array.from_string(data) + Ok(crypto.sign_message(data, key, Sha256)) +} + +pub fn create_cookie( + pevensie: Pevensie( + user_metadata, + auth_driver, + Connected, + cache_driver, + cache_status, + ), + session: Session, +) -> Result(String, Nil) { + let assert auth.AuthConfig(cookie_key:, ..) = pevensie.auth_config + io.println("Creating hash") + use hash <- result.try(sha256_hash(session.id, cookie_key)) + io.println("Hash created") + Ok(session.id <> "|" <> hash) +} + +// TODO: Improve errors +pub fn verify_cookie( + pevensie: Pevensie( + user_metadata, + auth_driver, + Connected, + cache_driver, + cache_status, + ), + cookie: String, +) -> Result(String, Nil) { + let assert auth.AuthConfig(cookie_key:, ..) = pevensie.auth_config + let cookie_parts = string.split(cookie, "|") + case cookie_parts { + [session_id, hash_string] -> { + use new_hash <- result.try(sha256_hash(session_id, cookie_key)) + case new_hash == hash_string { + True -> Ok(session_id) + False -> Error(Nil) + } + } + _ -> Error(Nil) + } +} diff --git a/src/pevensie/drivers.gleam b/src/pevensie/drivers.gleam index 047d648..e723898 100644 --- a/src/pevensie/drivers.gleam +++ b/src/pevensie/drivers.gleam @@ -1,7 +1,9 @@ import gleam/dynamic.{type Decoder} import gleam/option.{type Option} import pevensie/internal/encoder.{type Encoder} +import pevensie/internal/session.{type Session} import pevensie/internal/user.{type User, type UserInsert, type UserUpdate} +import pevensie/net.{type IpAddress} pub type Connected @@ -64,6 +66,35 @@ type DeleteUserFunction(auth_driver, user_metadata) = fn(auth_driver, String, String, Decoder(user_metadata)) -> Result(User(user_metadata), Nil) +/// A function that gets a session by ID. +/// Args: +/// - auth_driver: The auth driver to use. +/// - session_id: The ID of the session to get. +/// - ip: The IP address of the user. +/// - user_agent: The user agent of the user. +type GetSessionFunction(auth_driver) = + fn(auth_driver, String, Option(IpAddress), Option(String)) -> + Result(Option(Session), Nil) + +/// A function that creates a new session for a user. +/// Args: +/// - auth_driver: The auth driver to use. +/// - user_id: The ID of the user to create a session for. +/// - ip: The IP address of the user. +/// - user_agent: The user agent of the user. +/// - ttl_seconds: The number of seconds the session should last for. +/// - delete_other_sessions: Whether to delete any other sessions for the user. +type CreateSessionFunction(auth_driver) = + fn(auth_driver, String, Option(IpAddress), Option(String), Option(Int), Bool) -> + Result(Session, Nil) + +/// A function that deletes a session by ID. +/// Args: +/// - auth_driver: The auth driver to use. +/// - session_id: The ID of the session to delete. +type DeleteSessionFunction(auth_driver) = + fn(auth_driver, String) -> Result(Nil, Nil) + pub type AuthDriver(driver, user_metadata) { AuthDriver( driver: driver, @@ -73,6 +104,9 @@ pub type AuthDriver(driver, user_metadata) { insert_user: InsertUserFunction(driver, user_metadata), update_user: UpdateUserFunction(driver, user_metadata), delete_user: DeleteUserFunction(driver, user_metadata), + get_session: GetSessionFunction(driver), + create_session: CreateSessionFunction(driver), + delete_session: DeleteSessionFunction(driver), ) } diff --git a/src/pevensie/drivers/postgres.gleam b/src/pevensie/drivers/postgres.gleam index 5492ac8..0c8b6c2 100644 --- a/src/pevensie/drivers/postgres.gleam +++ b/src/pevensie/drivers/postgres.gleam @@ -18,10 +18,12 @@ import pevensie/drivers.{ type AuthDriver, type CacheDriver, AuthDriver, CacheDriver, } import pevensie/internal/encoder.{type Encoder} +import pevensie/internal/session.{type Session, Session} import pevensie/internal/user.{ type UpdateField, type User, type UserInsert, type UserUpdate, Ignore, Set, User, app_metadata_to_json, } +import pevensie/net.{type IpAddress} pub type IpVersion { Ipv4 @@ -139,6 +141,44 @@ pub fn new_auth_driver( Nil }) }, + get_session: fn(driver, session_id, ip, user_agent) { + get_session(driver, session_id, ip, user_agent) + // TODO: Handle errors + |> result.map_error(fn(err) { + io.debug(err) + Nil + }) + }, + create_session: fn( + driver, + user_id, + ip, + user_agent, + ttl_seconds, + delete_other_sessions, + ) { + create_session( + driver, + user_id, + ip, + user_agent, + ttl_seconds, + delete_other_sessions, + ) + // TODO: Handle errors + |> result.map_error(fn(err) { + io.debug(err) + Nil + }) + }, + delete_session: fn(driver, session_id) { + delete_session(driver, session_id) + // TODO: Handle errors + |> result.map_error(fn(err) { + io.debug(err) + Nil + }) + }, ) } @@ -487,6 +527,12 @@ fn update_user( }) |> string.join(", ") + // Add the updated_at field to the list of fields to update + let field_setters = case field_setters { + "" -> "updated_at = now()" + _ -> field_setters <> ", updated_at = now()" + } + let update_values = fields_to_update |> list.map(pair.second) @@ -499,9 +545,6 @@ fn update_user( ) <> " and deleted_at is null returning " <> user_select_fields - sql - |> io.debug - let query_result = pgo.execute( sql, @@ -552,13 +595,261 @@ fn delete_user( } } +const session_select_fields = " + id::text, + user_id::text, + (extract(epoch from created_at) * 1000000)::bigint as created_at, + (extract(epoch from expires_at) * 1000000)::bigint as expires_at, + host(ip)::text, + user_agent +" + +fn postgres_session_decoder() -> Decoder(Session) { + fn(data) { + io.debug(data) + decode.into({ + use id <- decode.parameter + use user_id <- decode.parameter + use created_at_tuple <- decode.parameter + use expires_at_tuple <- decode.parameter + use ip_string <- decode.parameter + use user_agent <- decode.parameter + + let created_at = birl.from_unix_micro(created_at_tuple) + let expires_at = case expires_at_tuple { + None -> None + Some(expires_at_tuple) -> Some(birl.from_unix_micro(expires_at_tuple)) + } + + use ip <- result.try(case ip_string { + None -> Ok(None) + Some(ip_string) -> { + net.parse_ip_address(ip_string) + |> result.map_error(fn(_) { + [ + dynamic.DecodeError( + expected: "IP address", + found: ip_string, + path: [], + ), + ] + }) + |> result.map(Some) + } + }) + + Ok(Session(id:, created_at:, expires_at:, user_id:, ip:, user_agent:)) + }) + |> decode.field(0, decode.string) + |> decode.field(1, decode.string) + |> decode.field(2, decode.int) + |> decode.field(3, decode.optional(decode.int)) + |> decode.field(4, decode.optional(decode.string)) + |> decode.field(5, decode.optional(decode.string)) + |> decode.from(data) + |> result.flatten + } +} + +pub fn get_session( + driver: Postgres, + session_id: String, + ip: Option(IpAddress), + user_agent: Option(String), +) -> Result(Option(Session), PostgresError) { + let assert Postgres(_, Some(conn)) = driver + + let sql = " + select + " <> session_select_fields <> " + from pevensie.\"session\" + where id = $1 + " + + let additional_fields = [ + #( + case ip { + None -> "ip is $" + Some(_) -> "ip = $" + }, + pgo.nullable(pgo.text, ip |> option.map(net.format_ip_address)), + ), + #( + case user_agent { + None -> "user_agent is $" + Some(_) -> "user_agent = $" + }, + pgo.nullable(pgo.text, user_agent), + ), + ] + + let sql = + list.index_fold(additional_fields, sql, fn(sql, field, index) { + sql <> " and " <> field.0 <> int.to_string(index + 2) + }) + + let query_result = + pgo.execute( + sql, + conn, + [ + pgo.text(session_id), + pgo.nullable(pgo.text, ip |> option.map(net.format_ip_address)), + pgo.nullable(pgo.text, user_agent), + ], + postgres_session_decoder(), + ) + // TODO: Handle errors + |> result.map_error(QueryError) + + use response <- result.try(query_result) + case response.rows { + [] -> Ok(None) + [session] -> Ok(Some(session)) + _ -> Error(InternalError("Unexpected number of rows returned")) + } +} + +fn delete_sessions_for_user( + driver: Postgres, + user_id: String, + except ignored_session_id: String, +) -> Result(Nil, PostgresError) { + let assert Postgres(_, Some(conn)) = driver + + let sql = + " + delete from pevensie.\"session\" + where user_id = $1 and id != $2 + returning id + " + + let query_result = + pgo.execute( + sql, + conn, + [pgo.text(user_id), pgo.text(ignored_session_id)], + fn(_) { Ok(json.null()) }, + ) + // TODO: Handle errors + |> result.map_error(QueryError) + + case query_result { + Ok(_) -> Ok(Nil) + Error(err) -> Error(err) + } +} + +pub fn create_session( + driver: Postgres, + user_id: String, + ip: Option(IpAddress), + user_agent: Option(String), + ttl_seconds: Option(Int), + delete_other_sessions: Bool, +) -> Result(Session, PostgresError) { + let assert Postgres(_, Some(conn)) = driver + + let expires_at_sql = case ttl_seconds { + None -> "null" + Some(ttl_seconds) -> + "now() + interval '" <> int.to_string(ttl_seconds) <> " seconds'" + } + + // inet is a weird type and doesn't work with pgo, + // so we have to cast it to text. + // This is fine because the `IpAddress` type is guaranteed + // to be a valid IP address, so there's no chance of + // SQL injection. + let ip_string = case ip { + None -> "null" + Some(ip) -> "'" <> net.format_ip_address(ip) <> "'::inet" + } + + let _ = + net.parse_ip_address("127.0.0.1") + |> io.debug + + let sql = " + insert into pevensie.\"session\" ( + user_id, + ip, + user_agent, + expires_at + ) values ( + $1, + " <> ip_string <> ", + $2, + " <> expires_at_sql <> " + ) + returning + " <> session_select_fields + + let query_result = + pgo.execute( + sql, + conn, + [ + pgo.text(user_id), + // pgo.nullable(pgo.text, ip |> option.map(net.format_ip_address)), + pgo.nullable(pgo.text, user_agent), + ], + postgres_session_decoder(), + ) + // TODO: Handle errors + |> result.map_error(QueryError) + + use response <- result.try(query_result) + case response.rows { + [] -> Error(NotFound) + [session] -> { + case delete_other_sessions { + True -> { + use _ <- result.try(delete_sessions_for_user( + driver, + user_id, + session.id, + )) + Ok(session) + } + False -> Ok(session) + } + } + _ -> Error(InternalError("Unexpected number of rows returned")) + } +} + +pub fn delete_session( + driver: Postgres, + session_id: String, +) -> Result(Nil, PostgresError) { + let assert Postgres(_, Some(conn)) = driver + + let sql = + " + delete from pevensie.\"session\" + where id = $1 + returning id + " + + let query_result = + pgo.execute(sql, conn, [pgo.text(session_id)], fn(_) { Ok(json.null()) }) + // TODO: Handle errors + |> result.map_error(QueryError) + + case query_result { + Ok(_) -> Ok(Nil) + Error(err) -> Error(err) + } +} + pub fn new_cache_driver(config: PostgresConfig) -> CacheDriver(Postgres) { CacheDriver( driver: Postgres(config |> postgres_config_to_pgo_config, None), connect: connect, disconnect: disconnect, store: fn(driver, resource_type, key, value, ttl_seconds) { - store(driver, resource_type, key, value, ttl_seconds) + store_in_cache(driver, resource_type, key, value, ttl_seconds) // TODO: Handle errors |> result.map_error(fn(err) { io.debug(err) @@ -566,7 +857,7 @@ pub fn new_cache_driver(config: PostgresConfig) -> CacheDriver(Postgres) { }) }, get: fn(driver, resource_type, key) { - get(driver, resource_type, key) + get_from_cache(driver, resource_type, key) // TODO: Handle errors |> result.map_error(fn(err) { io.debug(err) @@ -574,7 +865,7 @@ pub fn new_cache_driver(config: PostgresConfig) -> CacheDriver(Postgres) { }) }, delete: fn(driver, resource_type, key) { - delete(driver, resource_type, key) + delete_from_cache(driver, resource_type, key) // TODO: Handle errors |> result.map_error(fn(err) { io.debug(err) @@ -584,7 +875,7 @@ pub fn new_cache_driver(config: PostgresConfig) -> CacheDriver(Postgres) { ) } -fn store( +fn store_in_cache( driver: Postgres, resource_type: String, key: String, @@ -628,7 +919,7 @@ fn store( } } -fn get( +fn get_from_cache( driver: Postgres, resource_type: String, key: String, @@ -668,14 +959,17 @@ fn get( // If the value has expired, return None and delete the key // in an async task [#(_, True)] -> { - process.start(fn() { delete(driver, resource_type, key) }, False) + process.start( + fn() { delete_from_cache(driver, resource_type, key) }, + False, + ) Ok(None) } _ -> Error(InternalError("Unexpected number of rows returned")) } } -fn delete( +fn delete_from_cache( driver: Postgres, resource_type: String, key: String, diff --git a/src/pevensie/internal/auth.gleam b/src/pevensie/internal/auth.gleam index 79506dd..ea93a04 100644 --- a/src/pevensie/internal/auth.gleam +++ b/src/pevensie/internal/auth.gleam @@ -7,6 +7,7 @@ pub type AuthConfig(driver, user_metadata, connected) { driver: AuthDriver(driver, user_metadata), user_metadata_decoder: Decoder(user_metadata), user_metadata_encoder: Encoder(user_metadata), + cookie_key: String, ) AuthDisabled } diff --git a/src/pevensie/internal/pevensie.gleam b/src/pevensie/internal/pevensie.gleam index 49e9ee1..3d76e5c 100644 --- a/src/pevensie/internal/pevensie.gleam +++ b/src/pevensie/internal/pevensie.gleam @@ -61,7 +61,12 @@ pub fn connect_auth( Nil, ) { let assert Pevensie( - AuthConfig(auth_driver, user_metadata_decoder, user_metadata_encoder), + AuthConfig( + driver: auth_driver, + user_metadata_decoder:, + user_metadata_encoder:, + cookie_key:, + ), cache_config, ) = pevensie @@ -72,6 +77,7 @@ pub fn connect_auth( driver: AuthDriver(..auth_driver, driver: internal_driver), user_metadata_decoder:, user_metadata_encoder:, + cookie_key:, ), cache_config:, ) @@ -91,7 +97,12 @@ pub fn disconnect_auth( Nil, ) { let assert Pevensie( - AuthConfig(auth_driver, user_metadata_decoder, user_metadata_encoder), + AuthConfig( + driver: auth_driver, + user_metadata_decoder:, + user_metadata_encoder:, + cookie_key:, + ), cache_config, ) = pevensie @@ -102,6 +113,7 @@ pub fn disconnect_auth( driver: AuthDriver(..auth_driver, driver: internal_driver), user_metadata_decoder:, user_metadata_encoder:, + cookie_key:, ), cache_config:, ) diff --git a/src/pevensie/internal/session.gleam b/src/pevensie/internal/session.gleam new file mode 100644 index 0000000..18c8517 --- /dev/null +++ b/src/pevensie/internal/session.gleam @@ -0,0 +1,14 @@ +import birl.{type Time} +import gleam/option.{type Option} +import pevensie/net.{type IpAddress} + +pub type Session { + Session( + id: String, + created_at: Time, + expires_at: Option(Time), + user_id: String, + ip: Option(IpAddress), + user_agent: Option(String), + ) +} diff --git a/src/pevensie/net.gleam b/src/pevensie/net.gleam new file mode 100644 index 0000000..0d9e2a8 --- /dev/null +++ b/src/pevensie/net.gleam @@ -0,0 +1,59 @@ +import gleam/erlang/atom.{type Atom} +import gleam/erlang/charlist.{type Charlist} +import gleam/int +import gleam/list +import gleam/result +import gleam/string + +pub type IpAddress { + IpV4(Int, Int, Int, Int) + IpV6(Int, Int, Int, Int, Int, Int, Int, Int) +} + +// These accept Erlang `string()`s, which are not the same as Gleam `String`s. +// We convert them to Erlang `string()`s (equivalent to Gleam `List(String)`s +// where each element is a single character) before passing them to the +// Erlang function. +@external(erlang, "inet", "parse_ipv4_address") +fn inet_parse_ipv4(ip: Charlist) -> Result(#(Int, Int, Int, Int), Atom) + +@external(erlang, "inet", "parse_ipv6_address") +fn inet_parse_ipv6( + ip: Charlist, +) -> Result(#(Int, Int, Int, Int, Int, Int, Int, Int), Atom) + +fn parse_ipv4(ip: String) -> Result(IpAddress, Nil) { + ip + |> charlist.from_string + |> inet_parse_ipv4 + |> result.map(fn(ip) { IpV4(ip.0, ip.1, ip.2, ip.3) }) + |> result.replace_error(Nil) +} + +fn parse_ipv6(ip: String) -> Result(IpAddress, Nil) { + ip + |> charlist.from_string + |> inet_parse_ipv6 + |> result.map(fn(ip) { IpV6(ip.0, ip.1, ip.2, ip.3, ip.4, ip.5, ip.6, ip.7) }) + |> result.replace_error(Nil) +} + +pub fn parse_ip_address(ip: String) -> Result(IpAddress, Nil) { + parse_ipv4(ip) + |> result.lazy_or(fn() { parse_ipv6(ip) }) +} + +pub fn format_ip_address(ip: IpAddress) -> String { + case ip { + IpV4(a, b, c, d) -> { + [a, b, c, d] + |> list.map(int.to_string) + |> string.join(".") + } + IpV6(a, b, c, d, e, f, g, h) -> { + [a, b, c, d, e, f, g, h] + |> list.map(int.to_string) + |> string.join(":") + } + } +} diff --git a/v1.sql b/v1.sql index 0320ae8..fcd9dd3 100644 --- a/v1.sql +++ b/v1.sql @@ -50,8 +50,9 @@ language plpgsql create schema if not exists pevensie; +-- user create table if not exists pevensie."user" ( - id uuid not null default uuid7(), + id uuid not null default uuid7() primary key, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), deleted_at timestamptz, @@ -67,9 +68,20 @@ create table if not exists pevensie."user" ( banned_until timestamptz ); -create unique index user_email_unique_idx on "user" (email, deleted_at) where (email is not null) nulls not distinct; +create unique index user_email_unique_idx on pevensie."user" (email, deleted_at) nulls not distinct where (email is not null); -create table if not exists pevensie."cache" ( +-- session +create table if not exists pevensie."session" ( + id uuid not null default uuid7() primary key, + created_at timestamptz not null default now(), + expires_at timestamptz, + user_id uuid not null references pevensie."user"(id), + ip inet, + user_agent text +); + +-- cache +create unlogged table if not exists pevensie."cache" ( resource_type text not null, key text not null unique, value text not null,