Publish shitty code
This commit is contained in:
commit
2e36ee2758
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
/.direnv
|
||||||
|
/config.toml
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM link WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "487d4f0b61f41ae9fedc59ebdb4755a2846509ccea2ea5a6b1cc81263fc17ccb"
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE link SET visit_count = visit_count + 1 WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "4d94b8d9c3af0a9cbddc706ee82869355500bfe6cb97f5e20e10ddfddd523136"
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, slug, destination FROM link WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "slug",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "destination",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "54b60a99c8cff423cf3b202a0f4f70158a445b5f7d3b867b4068ab6a5b6557a0"
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE link SET destination = $2 WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "c586609a7040e4a8191a904d527051c1540041d4c6305f0d8162bbef0d7bff0d"
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, destination FROM link WHERE slug = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "destination",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e83004dd947b684af5ea9319fe136910e75d96a24800fd884c6cb3c1b6a03a89"
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO link (id, slug, destination) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ecbfe9b3c87beef7162b5c5fc7e2cc25d5420a4dc5b02257f78d4afea7f9517c"
|
||||||
|
}
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"sqlfluff.dialect": "postgres"
|
||||||
|
}
|
2147
Cargo.lock
generated
Normal file
2147
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
26
Cargo.toml
Normal file
26
Cargo.toml
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
[package]
|
||||||
|
name = "ncpn"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.6.20", default-features = false, features = ["http1", "json", "macros", "matched-path", "tokio", "tower-log", "tracing"] }
|
||||||
|
eyre = "0.6.8"
|
||||||
|
figment = { version = "0.10.11", features = ["env", "toml"] }
|
||||||
|
http = "0.2.9"
|
||||||
|
serde = { version = "1.0.189", features = ["derive"] }
|
||||||
|
sqlx = { version = "0.7.3", features = ["runtime-tokio", "postgres", "uuid"] }
|
||||||
|
thiserror = "1.0.51"
|
||||||
|
tokio = { version = "1.33.0", features = ["rt-multi-thread", "macros"] }
|
||||||
|
tower-http = { version = "0.4.4", features = ["trace"] }
|
||||||
|
tracing = "0.1.37"
|
||||||
|
tracing-subscriber = "0.3.17"
|
||||||
|
ulid = { version = "1.1.0", features = ["uuid", "serde"] }
|
||||||
|
url = { version = "2.5.0", features = ["serde"] }
|
||||||
|
uuid = "1.7.0"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
116
flake.lock
Normal file
116
flake.lock
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1705309234,
|
||||||
|
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils_2": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1705309234,
|
||||||
|
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1706371002,
|
||||||
|
"narHash": "sha256-dwuorKimqSYgyu8Cw6ncKhyQjUDOyuXoxDTVmAXq88s=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "c002c6aa977ad22c60398daaa9be52f2203d0006",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils_2",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1706634984,
|
||||||
|
"narHash": "sha256-xn7lGPE8gRGBe3Lt8ESoN/uUHm7IrbiV7siupwjHX1o=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "883b84c426107a8ec020e7124f263d7c35a5bb9f",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
41
flake.nix
Normal file
41
flake.nix
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
description = "Non-creative project name";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
rust-overlay = {
|
||||||
|
url = "github:oxalica/rust-overlay";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
overlays = [ (import rust-overlay) ];
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system overlays;
|
||||||
|
};
|
||||||
|
rust = pkgs.rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
|
||||||
|
extensions = [ "rust-src" ];
|
||||||
|
});
|
||||||
|
in
|
||||||
|
with pkgs;
|
||||||
|
{
|
||||||
|
devShells.default = mkShell rec {
|
||||||
|
buildInputs = [ rust ] ++ [
|
||||||
|
rust-analyzer
|
||||||
|
pkg-config
|
||||||
|
postgresql
|
||||||
|
sqlfluff
|
||||||
|
sqlx-cli
|
||||||
|
];
|
||||||
|
LD_LIBRARY_PATH = "${lib.makeLibraryPath buildInputs}";
|
||||||
|
# ssh -NL /home/honbra/.s.PGSQL.5432:/var/run/postgresql/.s.PGSQL.5432 <user>@<host>
|
||||||
|
# good luck setting up /home/honbra on your machine
|
||||||
|
DATABASE_URL = "postgresql:///ncpn?host=/home/honbra&user=honbra";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
1
migrations/20240128201312_create-link.down.sql
Normal file
1
migrations/20240128201312_create-link.down.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS link;
|
6
migrations/20240128201312_create-link.up.sql
Normal file
6
migrations/20240128201312_create-link.up.sql
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS link (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
destination TEXT NOT NULL,
|
||||||
|
visit_count INT NOT NULL DEFAULT 0
|
||||||
|
);
|
2
rust-toolchain.toml
Normal file
2
rust-toolchain.toml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
3
rustfmt.toml
Normal file
3
rustfmt.toml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
group_imports = "StdExternalCrate"
|
||||||
|
imports_granularity = "Crate"
|
||||||
|
wrap_comments = true
|
120
src/app/api/links.rs
Normal file
120
src/app/api/links.rs
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use http::StatusCode;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{query, PgPool};
|
||||||
|
use ulid::Ulid;
|
||||||
|
use url::Url;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
pub fn router(db: PgPool) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/", post(create_link))
|
||||||
|
.route(
|
||||||
|
"/:id",
|
||||||
|
get(get_link_info).put(update_link).delete(delete_link),
|
||||||
|
)
|
||||||
|
.with_state(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Link {
|
||||||
|
id: Ulid,
|
||||||
|
slug: String,
|
||||||
|
destination: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_link_info(
|
||||||
|
State(db): State<PgPool>,
|
||||||
|
Path(id): Path<Ulid>,
|
||||||
|
) -> Result<Json<Link>, AppError> {
|
||||||
|
let link = query!(
|
||||||
|
"SELECT id, slug, destination FROM link WHERE id = $1",
|
||||||
|
Uuid::from(id),
|
||||||
|
)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(Link {
|
||||||
|
id: Ulid::from(link.id),
|
||||||
|
slug: link.slug,
|
||||||
|
destination: link.destination,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateLinkRequestBody {
|
||||||
|
slug: String,
|
||||||
|
destination: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_link(
|
||||||
|
State(db): State<PgPool>,
|
||||||
|
Json(CreateLinkRequestBody { slug, destination }): Json<CreateLinkRequestBody>,
|
||||||
|
) -> Result<Json<Link>, AppError> {
|
||||||
|
let id = Ulid::new();
|
||||||
|
|
||||||
|
let result = query!(
|
||||||
|
"INSERT INTO link (id, slug, destination) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||||
|
Uuid::from(id),
|
||||||
|
slug,
|
||||||
|
destination.to_string(),
|
||||||
|
)
|
||||||
|
.execute(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match result.rows_affected() {
|
||||||
|
1 => Ok(Json(Link {
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
destination: destination.to_string(),
|
||||||
|
})),
|
||||||
|
0 => Err(AppError::LinkExists(id)),
|
||||||
|
rows => Err(AppError::ImpossibleAffectedRows(rows)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UpdateLinkRequestBody {
|
||||||
|
destination: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_link(
|
||||||
|
State(db): State<PgPool>,
|
||||||
|
Path(id): Path<Ulid>,
|
||||||
|
Json(UpdateLinkRequestBody { destination }): Json<UpdateLinkRequestBody>,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
let result = query!(
|
||||||
|
"UPDATE link SET destination = $2 WHERE id = $1",
|
||||||
|
Uuid::from(id),
|
||||||
|
destination.to_string(),
|
||||||
|
)
|
||||||
|
.execute(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match result.rows_affected() {
|
||||||
|
1 => Ok(StatusCode::NO_CONTENT),
|
||||||
|
0 => Err(AppError::LinkNotFound(id)),
|
||||||
|
rows => Err(AppError::ImpossibleAffectedRows(rows)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_link(
|
||||||
|
State(db): State<PgPool>,
|
||||||
|
Path(id): Path<Ulid>,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
let result = query!("DELETE FROM link WHERE id = $1", Uuid::from(id))
|
||||||
|
.execute(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match result.rows_affected() {
|
||||||
|
1 => Ok(StatusCode::NO_CONTENT),
|
||||||
|
0 => Err(AppError::LinkNotFound(id)),
|
||||||
|
rows => Err(AppError::ImpossibleAffectedRows(rows)),
|
||||||
|
}
|
||||||
|
}
|
8
src/app/api/mod.rs
Normal file
8
src/app/api/mod.rs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
mod links;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub fn router(db: PgPool) -> Router {
|
||||||
|
Router::new().nest("/links", links::router(db))
|
||||||
|
}
|
36
src/app/mod.rs
Normal file
36
src/app/mod.rs
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
mod api;
|
||||||
|
mod root;
|
||||||
|
|
||||||
|
use axum::{body::Body, Router};
|
||||||
|
use http::Request;
|
||||||
|
use sqlx::{postgres::PgConnectOptions, PgPool};
|
||||||
|
use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer};
|
||||||
|
use tracing::{field, span, Level};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
pub async fn build_app(config: Config) -> eyre::Result<Router> {
|
||||||
|
let db = PgPool::connect_with(
|
||||||
|
PgConnectOptions::new()
|
||||||
|
.application_name("ncpn")
|
||||||
|
.socket(&config.db_socket)
|
||||||
|
.username(&config.db_username)
|
||||||
|
.database(&config.db_database),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(root::router(db.clone(), config.default_destination)
|
||||||
|
.nest("/api", api::router(db))
|
||||||
|
.layer(
|
||||||
|
TraceLayer::new_for_http()
|
||||||
|
.make_span_with(|request: &Request<Body>| {
|
||||||
|
span!(
|
||||||
|
Level::INFO,
|
||||||
|
"http-request",
|
||||||
|
uri = field::display(request.uri()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.on_request(DefaultOnRequest::new().level(Level::DEBUG))
|
||||||
|
.on_response(DefaultOnResponse::new().level(Level::INFO)),
|
||||||
|
))
|
||||||
|
}
|
69
src/app/root.rs
Normal file
69
src/app/root.rs
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
response::Redirect,
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use sqlx::{query, PgPool};
|
||||||
|
use tracing::{error, field, instrument};
|
||||||
|
use ulid::Ulid;
|
||||||
|
use url::Url;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SharedState {
|
||||||
|
db: PgPool,
|
||||||
|
default_destination: Arc<Url>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(db: PgPool, default_destination: Url) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/:slug", get(redirect))
|
||||||
|
.with_state(SharedState {
|
||||||
|
db,
|
||||||
|
default_destination: Arc::new(default_destination),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn redirect(
|
||||||
|
State(SharedState {
|
||||||
|
db,
|
||||||
|
default_destination,
|
||||||
|
}): State<SharedState>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Result<Redirect, AppError> {
|
||||||
|
let result = query!("SELECT id, destination FROM link WHERE slug = $1", slug)
|
||||||
|
.fetch_optional(&db)
|
||||||
|
.await?
|
||||||
|
.map(|r| (Ulid::from(r.id), r.destination));
|
||||||
|
|
||||||
|
Ok(match result {
|
||||||
|
Some((id, destination)) => {
|
||||||
|
tokio::spawn(increase_visit_count(id, db));
|
||||||
|
Redirect::temporary(&destination)
|
||||||
|
}
|
||||||
|
None => Redirect::temporary(default_destination.as_str()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(db))]
|
||||||
|
async fn increase_visit_count(id: Ulid, db: PgPool) {
|
||||||
|
let result = query!(
|
||||||
|
"UPDATE link SET visit_count = visit_count + 1 WHERE id = $1",
|
||||||
|
Uuid::from(id),
|
||||||
|
)
|
||||||
|
.execute(&db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(result) if result.rows_affected() != 1 => {
|
||||||
|
error!(err = field::display(AppError::ImpossibleAffectedRows(result.rows_affected())));
|
||||||
|
}
|
||||||
|
Err(err) => error!(err = field::display(err)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
40
src/config.rs
Normal file
40
src/config.rs
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
use std::{net::SocketAddr, path::PathBuf};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default = "default_listen_addr")]
|
||||||
|
pub listen_addr: SocketAddr,
|
||||||
|
|
||||||
|
#[serde(default = "default_db_socket")]
|
||||||
|
pub db_socket: PathBuf,
|
||||||
|
#[serde(default = "default_db_username")]
|
||||||
|
pub db_username: String,
|
||||||
|
#[serde(default = "default_db_database")]
|
||||||
|
pub db_database: String,
|
||||||
|
|
||||||
|
#[serde(default = "default2_destination")]
|
||||||
|
pub default_destination: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_listen_addr() -> SocketAddr {
|
||||||
|
([0, 0, 0, 0], 3000).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_db_socket() -> PathBuf {
|
||||||
|
"/var/run/postgresql/".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_db_username() -> String {
|
||||||
|
"ncpn".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_db_database() -> String {
|
||||||
|
default_db_username()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default2_destination() -> Url {
|
||||||
|
"https://goob.cc/r".parse().expect("hardcoded URL is valid")
|
||||||
|
}
|
43
src/error.rs
Normal file
43
src/error.rs
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use http::StatusCode;
|
||||||
|
use tracing::{error, field};
|
||||||
|
use ulid::Ulid;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("link already exists ({0})")]
|
||||||
|
LinkExists(Ulid),
|
||||||
|
#[error("link not found ({0})")]
|
||||||
|
LinkNotFound(Ulid),
|
||||||
|
#[error("database returned an impossible number of affected rows ({0})")]
|
||||||
|
ImpossibleAffectedRows(u64),
|
||||||
|
#[error("database error")]
|
||||||
|
Database(#[from] sqlx::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Other(#[from] eyre::Report),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
error!(err = field::display(&self));
|
||||||
|
match self {
|
||||||
|
Self::LinkExists(_) => (StatusCode::BAD_REQUEST, "Link already exists").into_response(),
|
||||||
|
Self::LinkNotFound(_) => (StatusCode::NOT_FOUND, "Link not found").into_response(),
|
||||||
|
Self::ImpossibleAffectedRows(_) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Database returned an impossible number of affected rows",
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Self::Database(_) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"A database error has occured",
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Self::Other(err) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("An error has occured:\n{err:?}"),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
59
src/main.rs
Normal file
59
src/main.rs
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
mod app;
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
|
||||||
|
use eyre::Context;
|
||||||
|
use figment::{
|
||||||
|
providers::{Env, Format, Toml},
|
||||||
|
Figment,
|
||||||
|
};
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
use tracing::{debug, field, Level};
|
||||||
|
use tracing_subscriber::{filter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
use self::app::build_app;
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
fn main() -> eyre::Result<()> {
|
||||||
|
let filter = filter::Targets::new()
|
||||||
|
.with_target("honbra_api", Level::TRACE)
|
||||||
|
.with_target("tower_http::trace::on_response", Level::TRACE)
|
||||||
|
.with_target("tower_http::trace::on_request", Level::TRACE)
|
||||||
|
.with_default(Level::INFO);
|
||||||
|
|
||||||
|
let tracing_layer = tracing_subscriber::fmt::layer();
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(tracing_layer)
|
||||||
|
.with(filter)
|
||||||
|
.try_init()
|
||||||
|
.map_err(eyre::Error::msg)
|
||||||
|
.context("failed to initialize tracing subscriber")?;
|
||||||
|
|
||||||
|
let config: Config = Figment::new()
|
||||||
|
.merge(Toml::file("config.toml"))
|
||||||
|
.merge(Env::raw())
|
||||||
|
.extract()
|
||||||
|
.context("failed to parse config")?;
|
||||||
|
|
||||||
|
let rt = Runtime::new().context("failed to create tokio runtime")?;
|
||||||
|
|
||||||
|
rt.block_on(async move {
|
||||||
|
let listen_addr = config.listen_addr;
|
||||||
|
|
||||||
|
let router = build_app(config)
|
||||||
|
.await
|
||||||
|
.context("failed to build app")?
|
||||||
|
.into_make_service();
|
||||||
|
|
||||||
|
debug!(addr = field::display(&listen_addr), "binding");
|
||||||
|
|
||||||
|
axum::Server::try_bind(&listen_addr)
|
||||||
|
.context("unable to bind to server address")?
|
||||||
|
.serve(router)
|
||||||
|
.await
|
||||||
|
.context("server encountered a runtime error")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
Loading…
Reference in a new issue