WIP
test / test (push) Failing after 14s
Details
test / test (push) Failing after 14s
Details
This commit is contained in:
commit
cb79ef5547
|
@ -0,0 +1,7 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
|
@ -0,0 +1,23 @@
|
|||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
||||
- uses: https://github.com/erlef/setup-beam@v1
|
||||
with:
|
||||
otp-version: "26.0.2"
|
||||
gleam-version: "1.2.1"
|
||||
rebar3-version: "3"
|
||||
# elixir-version: "1.15.4"
|
||||
- run: gleam deps download
|
||||
- run: gleam test
|
||||
- run: gleam format --check src test
|
|
@ -0,0 +1,4 @@
|
|||
*.beam
|
||||
*.ez
|
||||
/build
|
||||
erl_crash.dump
|
|
@ -0,0 +1,19 @@
|
|||
# dumptruck
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
gleam run # Run the project
|
||||
gleam test # Run the tests
|
||||
gleam shell # Run an Erlang shell
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] Handle link GET
|
||||
- [x] Handle link POST
|
||||
- [x] Handle file POST
|
||||
- [x] Handle file GET
|
||||
- [ ] Switch to cuid
|
||||
- [ ] Full unit test coverage
|
||||
- [ ] Web Frontend
|
|
@ -0,0 +1,28 @@
|
|||
name = "dumptruck"
|
||||
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.0 and < 2.0.0"
|
||||
wisp = ">= 0.15.0 and < 1.0.0"
|
||||
mist = ">= 1.2.0 and < 2.0.0"
|
||||
gleam_http = ">= 3.6.0 and < 4.0.0"
|
||||
gleam_erlang = ">= 0.25.0 and < 1.0.0"
|
||||
gleam_otp = ">= 0.10.0 and < 1.0.0"
|
||||
ids = ">= 0.13.0 and < 1.0.0"
|
||||
envoy = ">= 1.0.1 and < 2.0.0"
|
||||
gleam_crypto = ">= 1.3.0 and < 2.0.0"
|
||||
gleam_pgo = ">= 0.11.0 and < 1.0.0"
|
||||
|
||||
[dev-dependencies]
|
||||
gleeunit = ">= 1.0.0 and < 2.0.0"
|
|
@ -0,0 +1,46 @@
|
|||
# This file was generated by Gleam
|
||||
# You typically do not need to edit this file
|
||||
|
||||
packages = [
|
||||
{ name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" },
|
||||
{ name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" },
|
||||
{ name = "envoy", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "CFAACCCFC47654F7E8B75E614746ED924C65BD08B1DE21101548AC314A8B6A41" },
|
||||
{ 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.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" },
|
||||
{ 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_pgo", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "pgo"], otp_app = "gleam_pgo", source = "hex", outer_checksum = "1897C299A05AE3DE6BF6CC92CD1D67FDF8504B15E8826CBEBFD0E4471A698DD3" },
|
||||
{ name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" },
|
||||
{ name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" },
|
||||
{ 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 = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" },
|
||||
{ name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" },
|
||||
{ name = "ids", version = "0.13.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "ids", source = "hex", outer_checksum = "D59147A8E7D818FEBE5CA572859CC018D0ACEFA84F5CED7CE86EDBCC10622B21" },
|
||||
{ name = "logging", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "FCB111401BDB4703A440A94FF8CC7DA521112269C065F219C2766998333E7738" },
|
||||
{ name = "marceau", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "5188D643C181EE350D8A20A3BDBD63AF7B6C505DE333CFBE05EF642ADD88A59B" },
|
||||
{ name = "mist", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "109B4D64E68C104CC23BB3CC5441ECD479DD7444889DA01113B75C6AF0F0E17B" },
|
||||
{ name = "opentelemetry_api", version = "1.3.0", build_tools = ["rebar3", "mix"], requirements = ["opentelemetry_semantic_conventions"], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "B9E5FF775FD064FA098DBA3C398490B77649A352B40B0B730A6B7DC0BDD68858" },
|
||||
{ name = "opentelemetry_semantic_conventions", version = "0.2.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_semantic_conventions", source = "hex", outer_checksum = "D61FA1F5639EE8668D74B527E6806E0503EFC55A42DB7B5F39939D84C07D6895" },
|
||||
{ name = "pg_types", version = "0.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "B02EFA785CAECECF9702C681C80A9CA12A39F9161A846CE17B01FB20AEEED7EB" },
|
||||
{ name = "pgo", version = "0.14.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "71016C22599936E042DC0012EE4589D24C71427D266292F775EBF201D97DF9C9" },
|
||||
{ name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" },
|
||||
{ name = "simplifile", version = "2.0.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "95219227A43FCFE62C6E494F413A1D56FF953B68FE420698612E3D89A1EFE029" },
|
||||
{ name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" },
|
||||
{ name = "wisp", version = "0.15.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 = "33D17A50276FE0A10E4F694E4EF7D99836954DC2D920D4B5741B1E0EBCAE403F" },
|
||||
]
|
||||
|
||||
[requirements]
|
||||
envoy = { version = ">= 1.0.1 and < 2.0.0" }
|
||||
gleam_crypto = { version = ">= 1.3.0 and < 2.0.0" }
|
||||
gleam_erlang = { version = ">= 0.25.0 and < 1.0.0" }
|
||||
gleam_http = { version = ">= 3.6.0 and < 4.0.0" }
|
||||
gleam_otp = { version = ">= 0.10.0 and < 1.0.0" }
|
||||
gleam_pgo = { version = ">= 0.11.0 and < 1.0.0" }
|
||||
gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" }
|
||||
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
|
||||
ids = { version = ">= 0.13.0 and < 1.0.0" }
|
||||
mist = { version = ">= 1.2.0 and < 2.0.0" }
|
||||
wisp = { version = ">= 0.15.0 and < 1.0.0" }
|
|
@ -0,0 +1,100 @@
|
|||
import envoy
|
||||
import gleam/int
|
||||
import gleam/pgo
|
||||
import gleam/result.{unwrap}
|
||||
import gleam/uri
|
||||
import wisp
|
||||
|
||||
pub type Context {
|
||||
Context(db: pgo.Connection, config: Config)
|
||||
}
|
||||
|
||||
pub type Config {
|
||||
Config(
|
||||
port: Int,
|
||||
max_bytes: Int,
|
||||
url_root: String,
|
||||
secret_key_base: String,
|
||||
postgres_host: String,
|
||||
postgres_db: String,
|
||||
postgres_user: String,
|
||||
postgres_password: String,
|
||||
postgres_port: Int,
|
||||
postgres_pool_size: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_int_or(var: String, default: Int) -> Int {
|
||||
case envoy.get(var) {
|
||||
Ok(value) -> int.parse(value) |> unwrap(default)
|
||||
Error(_) -> default
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_string_or(var: String, default: String) -> String {
|
||||
envoy.get(var) |> unwrap(default)
|
||||
}
|
||||
|
||||
pub fn generate_url(
|
||||
nano_id: String,
|
||||
root: String,
|
||||
ctx: Context,
|
||||
) -> Result(String, Nil) {
|
||||
uri.parse(
|
||||
ctx.config.url_root
|
||||
<> ":"
|
||||
<> ctx.config.port |> int.to_string
|
||||
<> "/"
|
||||
<> root
|
||||
<> "/"
|
||||
<> nano_id,
|
||||
)
|
||||
|> result.map(uri.to_string)
|
||||
}
|
||||
|
||||
// fn get_path_or(var: String, default: List(String)) -> List(String) {
|
||||
// case envoy.get(var) {
|
||||
// Ok(path_raw) -> {
|
||||
// string.split(path_raw, "/")
|
||||
// }
|
||||
// Error(_) -> default
|
||||
// }
|
||||
// }
|
||||
|
||||
pub fn load_config_from_env() -> Config {
|
||||
let defaults =
|
||||
Config(
|
||||
port: 8080,
|
||||
max_bytes: 1024 * 1024 * 50,
|
||||
url_root: "http://localhost",
|
||||
secret_key_base: wisp.random_string(64),
|
||||
postgres_host: "localhost",
|
||||
postgres_db: "dumptruck",
|
||||
postgres_user: "dumptruck",
|
||||
postgres_password: "dumptruck",
|
||||
postgres_port: 5432,
|
||||
postgres_pool_size: 15,
|
||||
)
|
||||
Config(
|
||||
port: "PORT"
|
||||
|> get_int_or(defaults.port),
|
||||
max_bytes: "MAX_BYTES"
|
||||
|> get_int_or(defaults.max_bytes),
|
||||
url_root: "URL_ROOT"
|
||||
|> get_string_or(defaults.url_root),
|
||||
secret_key_base: "SECRET_KEY_BASE"
|
||||
|> get_string_or(defaults.secret_key_base),
|
||||
postgres_host: "POSTGRES_HOST"
|
||||
|> get_string_or(defaults.postgres_host),
|
||||
postgres_db: "POSTGRES_DB"
|
||||
|> get_string_or(defaults.postgres_db),
|
||||
postgres_user: "POSTGRES_USER"
|
||||
|> get_string_or(defaults.postgres_user),
|
||||
postgres_password: "POSTGRES_PASSWORD"
|
||||
|> get_string_or(defaults.postgres_password),
|
||||
postgres_pool_size: "POSTGRES_POOL_SIZE"
|
||||
|> get_int_or(defaults.postgres_pool_size),
|
||||
postgres_port: "POSTGRES_PORT"
|
||||
|> get_int_or(defaults.postgres_port),
|
||||
)
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import app/config.{type Context}
|
||||
import app/web
|
||||
import app/web/file
|
||||
import app/web/link
|
||||
import app/web/root
|
||||
import wisp.{type Request, type Response}
|
||||
|
||||
pub fn handle_request(req: Request, ctx: Context) -> Response {
|
||||
use req: Request <- web.middleware(req)
|
||||
|
||||
case wisp.path_segments(req) {
|
||||
["f", ..] -> file.handle_request(req, ctx)
|
||||
["l", ..] -> link.handle_request(req, ctx)
|
||||
[] -> root.handle_request(req)
|
||||
_ -> wisp.not_found()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import wisp.{type Request, type Response}
|
||||
|
||||
pub const html_head = "
|
||||
<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">
|
||||
<html xmlns=\"http://www.w3.org/1999/xhtml\">
|
||||
<head>
|
||||
<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" />
|
||||
<title>Dumptruck</title>
|
||||
<style type=\"text/css\">
|
||||
html, body { color: #FFFADE; background: #21252D; font-size: 11pt; }
|
||||
</style>
|
||||
</head>
|
||||
"
|
||||
|
||||
pub const html_tail = "
|
||||
</html>
|
||||
"
|
||||
|
||||
pub fn middleware(
|
||||
req: Request,
|
||||
handle_request: fn(Request) -> Response,
|
||||
) -> 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)
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
import app/config.{type Context}
|
||||
import gleam/bit_array
|
||||
import gleam/crypto
|
||||
import gleam/dynamic
|
||||
import gleam/http.{Get, Post}
|
||||
import gleam/list
|
||||
import gleam/pgo
|
||||
import gleam/result
|
||||
import gleam/string
|
||||
import ids/nanoid
|
||||
import wisp.{type Request, type Response}
|
||||
|
||||
const schema = "
|
||||
CREATE TABLE IF NOT EXISTS \"File\" (
|
||||
nanoid VARCHAR(21) PRIMARY KEY,
|
||||
Md5Hash VARCHAR(32)
|
||||
FileName TEXT
|
||||
);
|
||||
"
|
||||
|
||||
pub type File {
|
||||
File(nano_id: String, md5_hash: String, file_name: String)
|
||||
}
|
||||
|
||||
pub fn prepare_database(ctx: Context) -> Result(Nil, pgo.QueryError) {
|
||||
pgo.execute(schema, ctx.db, [], dynamic.dynamic)
|
||||
|> result.map(fn(_) { Nil })
|
||||
}
|
||||
|
||||
pub fn store(file: File, ctx: Context) -> Result(String, Nil) {
|
||||
let sql =
|
||||
"INSERT INTO \"File\" (NanoID, Md5Hash) VALUES ($1, $2) RETURNING NanoID;"
|
||||
case
|
||||
pgo.execute(
|
||||
sql,
|
||||
ctx.db,
|
||||
[pgo.text(file.nano_id), pgo.text(file.md5_hash)],
|
||||
dynamic.element(0, dynamic.string),
|
||||
)
|
||||
{
|
||||
Ok(target_id) ->
|
||||
target_id.rows |> list.first |> result.map_error(fn(_) { Nil })
|
||||
Error(_) -> Error(Nil)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retrieve(nano_id: String, ctx: Context) -> Result(File, Nil) {
|
||||
let sql = "SELECT NanoID,Md5Hash,FileName FROM \"File\" WHERE NanoID = $1;"
|
||||
case
|
||||
pgo.execute(
|
||||
sql,
|
||||
ctx.db,
|
||||
[pgo.text(nano_id)],
|
||||
dynamic.tuple3(dynamic.string, dynamic.string, dynamic.string),
|
||||
)
|
||||
{
|
||||
Ok(res) ->
|
||||
case res.rows |> list.first {
|
||||
Ok(#(nano_id, md5_hash, file_name)) ->
|
||||
Ok(File(nano_id, md5_hash, file_name))
|
||||
_ -> Error(Nil)
|
||||
}
|
||||
_ -> Error(Nil)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hash_to_path(hash: String) -> String {
|
||||
[""]
|
||||
|> list.append([string.slice(hash, 0, 2)])
|
||||
|> list.append([string.slice(hash, 2, 2)])
|
||||
|> list.append([string.slice(hash, 4, string.length(hash) - 4)])
|
||||
|> string.join("/")
|
||||
}
|
||||
|
||||
pub fn serve_hash(req: Request, md5_hash: String) -> Response {
|
||||
use <- wisp.require_method(req, Get)
|
||||
let path = hash_to_path(md5_hash)
|
||||
// TODO: Test mimetype
|
||||
wisp.ok()
|
||||
|> wisp.set_header("Content-Type", "application/octet-stream")
|
||||
|> wisp.file_download(named: "blob", from: path)
|
||||
}
|
||||
|
||||
pub fn handle_get(req: Request, ctx: Context) -> Response {
|
||||
use <- wisp.require_method(req, Get)
|
||||
case wisp.path_segments(req) {
|
||||
["f", nano_id] ->
|
||||
case retrieve(nano_id, ctx) {
|
||||
Ok(file) -> serve_hash(req, file.md5_hash)
|
||||
Error(_) -> wisp.not_found()
|
||||
}
|
||||
_ -> wisp.not_found()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_post(req: Request, ctx: Context) -> Response {
|
||||
use <- wisp.require_method(req, Post)
|
||||
use body <- wisp.require_bit_array_body(req)
|
||||
|
||||
case wisp.path_segments(req) {
|
||||
["f", path] -> {
|
||||
let nano_id = nanoid.generate()
|
||||
let md5_hash = crypto.hash(crypto.Md5, body) |> bit_array.base16_encode
|
||||
let file = File(nano_id, md5_hash, path)
|
||||
|
||||
case store(file, ctx) |> result.try(config.generate_url(_, "f", ctx)) {
|
||||
Ok(url) -> wisp.ok() |> wisp.string_body(url)
|
||||
Error(_) -> wisp.internal_server_error()
|
||||
}
|
||||
}
|
||||
_ -> wisp.not_found()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_request(req: Request, ctx: Context) -> Response {
|
||||
case req.method {
|
||||
Get -> handle_get(req, ctx)
|
||||
Post -> handle_post(req, ctx)
|
||||
_ -> wisp.not_found()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
import app/config.{type Context}
|
||||
import gleam/dynamic
|
||||
import gleam/http.{Get, Post}
|
||||
import gleam/list
|
||||
import gleam/pair
|
||||
import gleam/pgo
|
||||
import gleam/result
|
||||
import gleam/uri.{type Uri}
|
||||
import ids/nanoid
|
||||
import wisp.{type Request, type Response}
|
||||
|
||||
const schema = "
|
||||
CREATE TABLE IF NOT EXISTS \"Link\" (
|
||||
NanoID VARCHAR(21) PRIMARY KEY,
|
||||
TargetURL TEXT
|
||||
);
|
||||
"
|
||||
|
||||
pub type Link {
|
||||
Link(nano_id: String, target_url: Uri)
|
||||
}
|
||||
|
||||
pub fn prepare_database(ctx: Context) -> Result(Nil, pgo.QueryError) {
|
||||
pgo.execute(schema, ctx.db, [], dynamic.dynamic)
|
||||
|> result.map(fn(_) { Nil })
|
||||
}
|
||||
|
||||
pub fn store(link: Link, ctx: Context) -> Result(String, Nil) {
|
||||
let sql =
|
||||
"INSERT INTO \"Link\" (NanoID, TargetURL) VALUES ($1, $2) RETURNING NanoID;"
|
||||
case
|
||||
pgo.execute(
|
||||
sql,
|
||||
ctx.db,
|
||||
[pgo.text(link.nano_id), pgo.text(link.target_url |> uri.to_string)],
|
||||
dynamic.element(0, dynamic.string),
|
||||
)
|
||||
{
|
||||
Ok(target_id) ->
|
||||
target_id.rows |> list.first |> result.map_error(fn(_) { Nil })
|
||||
Error(_) -> Error(Nil)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retrieve(nano_id: String, ctx: Context) -> Result(Link, Nil) {
|
||||
let sql = "SELECT NanoID,TargetURL FROM \"Link\" WHERE NanoID = $1;"
|
||||
case
|
||||
pgo.execute(
|
||||
sql,
|
||||
ctx.db,
|
||||
[pgo.text(nano_id)],
|
||||
dynamic.tuple2(dynamic.string, dynamic.string),
|
||||
)
|
||||
{
|
||||
Ok(res) -> {
|
||||
let sql_response = res.rows |> list.first
|
||||
let nano_id = sql_response |> result.map(pair.first)
|
||||
let target_url =
|
||||
sql_response |> result.map(pair.second) |> result.try(uri.parse)
|
||||
|
||||
{
|
||||
use nano_id <- result.try(nano_id)
|
||||
use target_url <- result.try(target_url)
|
||||
Ok(Link(nano_id, target_url))
|
||||
}
|
||||
}
|
||||
_ -> Error(Nil)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_uri(url: Uri, ctx: Context) -> Response {
|
||||
store(nanoid.generate() |> Link(url), ctx)
|
||||
|> result.try(config.generate_url(_, "l", ctx))
|
||||
|> result.map(wisp.string_body(wisp.response(200), _))
|
||||
|> result.unwrap(wisp.response(500))
|
||||
}
|
||||
|
||||
pub fn handle_root(req: Request, ctx: Context) -> Response {
|
||||
use <- wisp.require_method(req, Post)
|
||||
use body <- wisp.require_string_body(req)
|
||||
case uri.parse(body) {
|
||||
Ok(uri) -> handle_uri(uri, ctx)
|
||||
Error(_) -> wisp.bad_request()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_nano_id(req: Request, nano_id: String, ctx: Context) -> Response {
|
||||
use <- wisp.require_method(req, Get)
|
||||
case retrieve(nano_id, ctx) {
|
||||
Ok(link) -> wisp.redirect(link.target_url |> uri.to_string)
|
||||
Error(_) -> wisp.not_found()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_request(req: Request, ctx: Context) -> Response {
|
||||
case wisp.path_segments(req) {
|
||||
["l"] -> handle_root(req, ctx)
|
||||
["l", nano_id] -> handle_nano_id(req, nano_id, ctx)
|
||||
_ -> wisp.not_found()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import app/web
|
||||
import gleam/http.{Get}
|
||||
import gleam/string_builder
|
||||
import wisp.{type Request, type Response}
|
||||
|
||||
pub fn handle_request(req: Request) -> Response {
|
||||
use <- wisp.require_method(req, Get)
|
||||
{ web.html_head <> "
|
||||
Welcome to Dumptruck!
|
||||
POST a file to /f to get a shareable link.
|
||||
POST a link to /l to get a shortened URL.
|
||||
" <> web.html_tail }
|
||||
|> string_builder.from_string
|
||||
|> wisp.html_response(200)
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import app/config.{type Config, type Context, Context}
|
||||
import app/router
|
||||
import gleam/erlang/process
|
||||
import gleam/pgo
|
||||
import mist
|
||||
import wisp
|
||||
|
||||
fn with_context(continue: fn(Context) -> Nil) -> Nil {
|
||||
let config = config.load_config_from_env()
|
||||
let db =
|
||||
pgo.connect(
|
||||
pgo.Config(
|
||||
..pgo.default_config(),
|
||||
host: config.postgres_host,
|
||||
database: config.postgres_db,
|
||||
pool_size: config.postgres_pool_size,
|
||||
),
|
||||
)
|
||||
let ctx = Context(db: db, config: config)
|
||||
|
||||
continue(ctx)
|
||||
|
||||
ctx.db |> pgo.disconnect
|
||||
}
|
||||
|
||||
pub fn main() {
|
||||
use ctx <- with_context()
|
||||
|
||||
wisp.configure_logger()
|
||||
|
||||
let assert Ok(_) =
|
||||
router.handle_request(_, ctx)
|
||||
|> wisp.mist_handler(config.secret_key_base)
|
||||
|> mist.new
|
||||
|> mist.port(config.port)
|
||||
|> mist.start_http
|
||||
|
||||
process.sleep_forever()
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import app/config.{type Config, Config}
|
||||
import envoy
|
||||
import gleam/int
|
||||
import gleeunit/should
|
||||
import wisp/testing
|
||||
|
||||
const default_config = Config(
|
||||
port: 8080,
|
||||
max_bytes: 52_428_800,
|
||||
url_root: "localhost",
|
||||
secret_key_base: testing.default_secret_key_base,
|
||||
postgres_pool_size: 15,
|
||||
postgres_port: 5432,
|
||||
postgres_host: "localhost",
|
||||
postgres_db: "dumptruck",
|
||||
postgres_user: "dumptruck",
|
||||
postgres_password: "dumptruck",
|
||||
)
|
||||
|
||||
fn setenv(conf: Config) {
|
||||
envoy.set("PORT", conf.port |> int.to_string)
|
||||
envoy.set("MAX_BYTES", conf.max_bytes |> int.to_string)
|
||||
envoy.set("URL_ROOT", conf.url_root)
|
||||
envoy.set("SECRET_KEY_BASE", conf.secret_key_base)
|
||||
envoy.set("POSTGRES_DB", conf.postgres_db)
|
||||
envoy.set("POSTGRES_HOST", conf.postgres_host)
|
||||
envoy.set("POSTGRES_USER", conf.postgres_user)
|
||||
envoy.set("POSTGRES_PASSWORD", conf.postgres_password)
|
||||
envoy.set("POSTGRES_PORT", conf.postgres_port |> int.to_string)
|
||||
envoy.set("POSTGRES_POOL_SIZE", conf.postgres_pool_size |> int.to_string)
|
||||
}
|
||||
|
||||
pub fn get_string_or_test() {
|
||||
envoy.unset("TEST_UNSET")
|
||||
config.get_string_or("TEST_UNSET", "ELSE") |> should.equal("ELSE")
|
||||
|
||||
envoy.set("TEST_SET", "YES")
|
||||
config.get_string_or("TEST_SET", "ELSE") |> should.equal("YES")
|
||||
}
|
||||
|
||||
pub fn get_int_or_test() {
|
||||
envoy.unset("TEST_UNSET")
|
||||
config.get_int_or("TEST_UNSET", 333) |> should.equal(333)
|
||||
|
||||
envoy.set("TEST_SET", "999")
|
||||
config.get_int_or("TEST_SET", 333) |> should.equal(999)
|
||||
}
|
||||
|
||||
pub fn load_config_from_env_test() {
|
||||
let test_config =
|
||||
Config(..default_config, port: 1234, max_bytes: 123, url_root: "test")
|
||||
|
||||
setenv(test_config)
|
||||
|
||||
let conf = config.load_config_from_env()
|
||||
conf.port |> should.equal(1234)
|
||||
conf.max_bytes |> should.equal(123)
|
||||
conf.url_root |> should.equal("test")
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import app/config.{type Context, Config, Context}
|
||||
import app/web/file
|
||||
import gleam/dynamic
|
||||
import gleam/option.{None, Some}
|
||||
import gleam/pgo
|
||||
import gleam/string
|
||||
import gleeunit/should
|
||||
|
||||
const test_config = Config(
|
||||
port: 8080,
|
||||
max_bytes: 52_428_800,
|
||||
url_root: "http://localhost",
|
||||
secret_key_base: "cDv6ikrODZKjKbwSBBwspXInc61MYpCzQ8My9gW2QpQg3Y3lT7IJ5RJlzRN5HZK0",
|
||||
postgres_db: "dumptruck",
|
||||
postgres_user: "dumptruck",
|
||||
postgres_password: "dumptruck",
|
||||
postgres_host: "localhost",
|
||||
postgres_pool_size: 15,
|
||||
postgres_port: 5432,
|
||||
)
|
||||
|
||||
fn with_context(testcase: fn(Context) -> t) -> t {
|
||||
let ctx =
|
||||
Context(
|
||||
db: pgo.Config(
|
||||
..pgo.default_config(),
|
||||
database: test_config.postgres_db,
|
||||
password: test_config.postgres_password |> Some,
|
||||
user: test_config.postgres_user,
|
||||
host: test_config.postgres_host,
|
||||
pool_size: test_config.postgres_pool_size,
|
||||
port: 5432,
|
||||
)
|
||||
|> pgo.connect,
|
||||
config: test_config,
|
||||
)
|
||||
|
||||
case file.prepare_database(ctx) {
|
||||
Ok(_) -> {
|
||||
let ret = testcase(ctx)
|
||||
|
||||
ctx.db |> pgo.disconnect
|
||||
|
||||
ret
|
||||
}
|
||||
Error(e) -> {
|
||||
e |> string.inspect |> panic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn null_test() {
|
||||
use ctx <- with_context()
|
||||
|
||||
pgo.execute(
|
||||
"select $1",
|
||||
ctx.db,
|
||||
[pgo.null()],
|
||||
dynamic.element(0, dynamic.optional(dynamic.int)),
|
||||
)
|
||||
|> should.equal(Ok(pgo.Returned(count: 1, rows: [None])))
|
||||
}
|
||||
|
||||
pub fn hash_to_path_test() {
|
||||
"f7b478961451483b8c251c788750d5ef"
|
||||
|> file.hash_to_path
|
||||
|> should.equal(["f7", "b4", "78961451483b8c251c788750d5ef"])
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
import app/config.{type Context, Config, Context}
|
||||
import app/web/link.{Link}
|
||||
import gleam/dynamic
|
||||
import gleam/option.{None, Some}
|
||||
import gleam/pgo
|
||||
import gleam/result
|
||||
import gleam/string
|
||||
import gleam/uri
|
||||
import gleeunit/should
|
||||
import ids/nanoid
|
||||
|
||||
const test_config = Config(
|
||||
port: 8080,
|
||||
max_bytes: 52_428_800,
|
||||
url_root: "http://localhost",
|
||||
secret_key_base: "cDv6ikrODZKjKbwSBBwspXInc61MYpCzQ8My9gW2QpQg3Y3lT7IJ5RJlzRN5HZK0",
|
||||
postgres_db: "dumptruck",
|
||||
postgres_user: "dumptruck",
|
||||
postgres_password: "dumptruck",
|
||||
postgres_host: "localhost",
|
||||
postgres_pool_size: 15,
|
||||
postgres_port: 5432,
|
||||
)
|
||||
|
||||
fn with_context(testcase: fn(Context) -> t) -> t {
|
||||
let ctx =
|
||||
Context(
|
||||
db: pgo.Config(
|
||||
..pgo.default_config(),
|
||||
database: test_config.postgres_db,
|
||||
password: test_config.postgres_password |> Some,
|
||||
user: test_config.postgres_user,
|
||||
host: test_config.postgres_host,
|
||||
pool_size: test_config.postgres_pool_size,
|
||||
port: 5432,
|
||||
)
|
||||
|> pgo.connect,
|
||||
config: test_config,
|
||||
)
|
||||
|
||||
case link.prepare_database(ctx) {
|
||||
Ok(_) -> {
|
||||
let ret = testcase(ctx)
|
||||
|
||||
ctx.db |> pgo.disconnect
|
||||
|
||||
ret
|
||||
}
|
||||
Error(e) -> {
|
||||
e |> string.inspect |> panic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn null_test() {
|
||||
use ctx <- with_context()
|
||||
|
||||
pgo.execute(
|
||||
"select $1",
|
||||
ctx.db,
|
||||
[pgo.null()],
|
||||
dynamic.element(0, dynamic.optional(dynamic.int)),
|
||||
)
|
||||
|> should.equal(Ok(pgo.Returned(count: 1, rows: [None])))
|
||||
}
|
||||
|
||||
pub fn generate_url_test() {
|
||||
use ctx <- with_context()
|
||||
let test_id = Ok("testid_generate_url")
|
||||
|
||||
link.generate_url(test_id, ctx)
|
||||
|> should.equal(uri.parse("http://localhost:8080/l/testid_generate_url"))
|
||||
}
|
||||
|
||||
pub fn store_link_test() {
|
||||
use ctx <- with_context()
|
||||
let assert Ok(test_url) = uri.parse("http://example.com/")
|
||||
let test_id = nanoid.generate()
|
||||
|
||||
let stored_nano_id =
|
||||
Link(test_id, test_url)
|
||||
|> link.store(ctx)
|
||||
|> result.try_recover(fn(e) { e |> string.inspect |> Ok })
|
||||
|> result.unwrap("Undefined Error")
|
||||
|
||||
stored_nano_id |> should.equal(test_id)
|
||||
|
||||
let assert Ok(stored_link) = stored_nano_id |> link.retrieve(ctx)
|
||||
stored_link.target_url |> should.equal(test_url)
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
services:
|
||||
db:
|
||||
image: postgres:latest
|
||||
environment:
|
||||
- POSTGRES_USER=dumptruck
|
||||
- POSTGRES_PASSWORD=dumptruck
|
||||
- POSTGRES_DB=dumptruck
|
||||
ports:
|
||||
- "5432:5432"
|
|
@ -0,0 +1,5 @@
|
|||
import gleeunit
|
||||
|
||||
pub fn main() {
|
||||
gleeunit.main()
|
||||
}
|
Loading…
Reference in New Issue