Initial doglinks

TODO
- style it at all
- rate limit
This commit is contained in:
asonix 2022-12-18 17:30:43 -06:00
commit 3404b377d3
10 changed files with 2133 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/server/target
/server/data
/server/static/main.js
/client/elm-stuff

9
build.sh Executable file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
pushd client
elm make src/Main.elm --output=../server/static/main.js --optimize
popd
pushd server
cargo build --release
popd

28
client/elm.json Normal file
View file

@ -0,0 +1,28 @@
{
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"NoRedInk/elm-json-decode-pipeline": "1.0.1",
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/http": "2.0.0",
"elm/json": "1.1.3",
"elm/url": "1.0.0"
},
"indirect": {
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.3"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

103
client/src/CreatePage.elm Normal file
View file

@ -0,0 +1,103 @@
module CreatePage exposing (Model, Msg, init, update, view)
import Browser.Navigation as Nav
import Html exposing (Html, text)
import Html.Attributes as Attr
import Html.Events as Events
import Http
import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Pipeline exposing (required)
import Json.Encode as Encode
type alias Model =
{ link : String
, error : Maybe String
}
init : Model
init =
{ link = ""
, error = Nothing
}
view : Model -> Html Msg
view model =
Html.div []
[ Html.form [ Events.onSubmit (ClickedCreate model.link) ]
[ Html.input [ Attr.type_ "text", Events.onInput LinkUpdated ] []
, Html.button [ Attr.type_ "submit" ] [ text "Create doglink" ]
]
, case model.error of
Just error ->
viewError error
Nothing ->
text ""
]
viewError : String -> Html msg
viewError error =
Html.div []
[ Html.h3 [] [ text "Couldn't create doglink" ]
, Html.p [] [ text error ]
]
type Msg
= ClickedCreate String
| LinkUpdated String
| GotUuid (Result Http.Error String)
update : String -> Nav.Key -> Msg -> Model -> ( Model, Cmd Msg )
update origin key msg model =
case msg of
ClickedCreate link ->
( model, createLink origin model )
LinkUpdated link ->
( { model | link = link }, Cmd.none )
GotUuid (Ok uuid) ->
( model, Nav.pushUrl key ("/" ++ uuid) )
GotUuid (Err error) ->
case error of
Http.BadUrl url ->
( { model | error = Just ("Bad url: " ++ url) }, Cmd.none )
Http.Timeout ->
( { model | error = Just "Request timed out" }, Cmd.none )
Http.NetworkError ->
( { model | error = Just "Couldn't talk to server" }, Cmd.none )
Http.BadStatus int ->
( { model | error = Just ("Server responded with invalid status: " ++ String.fromInt int) }, Cmd.none )
Http.BadBody body ->
( { model | error = Just ("Server responed with unexpected data: " ++ body) }, Cmd.none )
createLink : String -> Model -> Cmd Msg
createLink origin model =
Http.post
{ url = origin ++ "/link"
, body = Http.jsonBody (encodeLink model.link)
, expect = Http.expectJson GotUuid uuidDecoder
}
uuidDecoder : Decoder String
uuidDecoder =
Decode.succeed identity
|> required "uuid" Decode.string
encodeLink : String -> Encode.Value
encodeLink link =
Encode.object [ ( "url", Encode.string link ) ]

152
client/src/Main.elm Normal file
View file

@ -0,0 +1,152 @@
module Main exposing (..)
import Browser exposing (Document)
import Browser.Navigation as Nav
import CreatePage
import Html exposing (Html, text)
import ShowPage
import Url exposing (Url)
import Url.Parser as Parser exposing (Parser)
type alias Model =
{ origin : String
, key : Nav.Key
, page : Page
}
type Route
= Create
| Show String
type Page
= CreatePage CreatePage.Model
| ShowPage ShowPage.Model
view : Model -> Document Msg
view model =
{ title = "Doglinks"
, body =
[ Html.h1 [] [ text "Doglinks" ]
, Html.p [] [ text "Create your doglinks here" ]
, case model.page of
CreatePage page ->
CreatePage.view page
|> Html.map CreateMsg
ShowPage page ->
ShowPage.view model.origin page
|> Html.map ShowMsg
]
}
init : String -> Url -> Nav.Key -> ( Model, Cmd Msg )
init origin url key =
let
initialModel =
{ origin = origin, key = key, page = CreatePage CreatePage.init }
in
updateUrl url initialModel
type Msg
= ClickedLink Browser.UrlRequest
| ChangedUrl Url
| CreateMsg CreatePage.Msg
| ShowMsg ShowPage.Msg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ClickedLink (Browser.Internal url) ->
( model, Nav.pushUrl model.key <| Url.toString url )
ClickedLink _ ->
( model, Cmd.none )
ChangedUrl url ->
updateUrl url model
CreateMsg createMsg ->
case model.page of
CreatePage page ->
let
( pageModel, pageMsg ) =
CreatePage.update model.origin model.key createMsg page
in
( { model | page = CreatePage pageModel }, Cmd.map CreateMsg pageMsg )
_ ->
( model, Cmd.none )
ShowMsg showMsg ->
case model.page of
ShowPage page ->
let
( pageModel, pageMsg ) =
ShowPage.update model.key showMsg page
in
( { model | page = ShowPage pageModel }, Cmd.map ShowMsg pageMsg )
_ ->
( model, Cmd.none )
updateUrl : Url -> Model -> ( Model, Cmd Msg )
updateUrl url model =
case Parser.parse routeParser url of
Just (Show uuid) ->
toShow model ( ShowPage.init uuid, Cmd.none )
_ ->
toCreate model ( CreatePage.init, Cmd.none )
toShow : Model -> ( ShowPage.Model, Cmd ShowPage.Msg ) -> ( Model, Cmd Msg )
toShow model ( showModel, showMsg ) =
case model.page of
ShowPage _ ->
( model, Cmd.none )
_ ->
( { model | page = ShowPage showModel }, Cmd.map ShowMsg showMsg )
toCreate : Model -> ( CreatePage.Model, Cmd CreatePage.Msg ) -> ( Model, Cmd Msg )
toCreate model ( createModel, createMsg ) =
case model.page of
CreatePage _ ->
( model, Cmd.none )
_ ->
( { model | page = CreatePage createModel }, Cmd.map CreateMsg createMsg )
subscriptions : Model -> Sub Msg
subscriptions _ =
Sub.map ShowMsg ShowPage.subscriptions
main : Program String Model Msg
main =
Browser.application
{ init = init
, subscriptions = subscriptions
, update = update
, view = view
, onUrlRequest = ClickedLink
, onUrlChange = ChangedUrl
}
routeParser : Parser (Route -> a) a
routeParser =
Parser.oneOf
[ Parser.map Create Parser.top
, Parser.map Show Parser.string
]

81
client/src/ShowPage.elm Normal file
View file

@ -0,0 +1,81 @@
port module ShowPage exposing (Model, Msg, init, subscriptions, update, view)
import Browser
import Browser.Navigation as Nav
import Html exposing (Html, text)
import Html.Attributes as Attr
import Html.Events as Events
import Process
import Task
type alias Model =
{ copied : Bool
, uuid : String
}
init : String -> Model
init uuid =
{ copied = False
, uuid = uuid
}
view : String -> Model -> Html Msg
view origin model =
let
dogLink =
origin ++ "/link/" ++ model.uuid
in
Html.div []
[ Html.h3 [] [ text "Doglink created!" ]
, Html.div []
[ Html.input [ Attr.type_ "text", Attr.id "copy-doglink", Attr.value dogLink ] []
, Html.a [ Attr.href "#", Events.onClick ClickedCopy ]
[ if model.copied then
text "Copied!"
else
text "Copy"
]
]
, Html.div []
[ Html.a [ Attr.href "/" ] [ text "Return Home" ]
]
]
type Msg
= ClickedCopy
| LinkCopied
| ExpiredCopy
update : Nav.Key -> Msg -> Model -> ( Model, Cmd Msg )
update key msg model =
case msg of
ClickedCopy ->
( model, copyLink ())
LinkCopied ->
( { model | copied = True }, expireCopy )
ExpiredCopy ->
( { model | copied = False }, Cmd.none )
expireCopy : Cmd Msg
expireCopy =
Task.perform (\_ -> ExpiredCopy) (Process.sleep 3000)
port copyLink : () -> Cmd msg
port linkCopied : (() -> msg) -> Sub msg
subscriptions : Sub Msg
subscriptions =
linkCopied (\_ -> LinkCopied)

1529
server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
server/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "doglinks"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "4"
actix-web-lab = { version = "0.18.8", features = ["spa"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sled = "0.34.7"
url = { version = "2", features = ["serde"] }
uuid = { version = "1", features = ["serde", "v4"] }

184
server/src/main.rs Normal file
View file

@ -0,0 +1,184 @@
use actix_web::{
body::MessageBody,
dev::{ServiceRequest, ServiceResponse},
error::{ErrorBadRequest, ErrorInternalServerError},
http::header::LOCATION,
web::{self, Json, Path},
App, HttpResponse, HttpServer,
};
use actix_web_lab::{
middleware::{from_fn, Next},
web::spa,
};
use sled::{CompareAndSwapError, Config, Db, Tree};
use std::collections::BTreeSet;
use url::Url;
use uuid::Uuid;
#[derive(Clone)]
struct State {
link_tree: Tree,
uuid_tree: Tree,
_db: Db,
}
impl State {
fn build(db: Db) -> sled::Result<Self> {
Ok(State {
link_tree: db.open_tree("doglinks-link-tree")?,
uuid_tree: db.open_tree("doglinks-uuid-tree")?,
_db: db,
})
}
}
type StateValue = BTreeSet<Uuid>;
impl State {
async fn save_link(&self, link: Url) -> sled::Result<Uuid> {
let link_tree = self.link_tree.clone();
let uuid_tree = self.uuid_tree.clone();
web::block(move || {
let uuid = Uuid::new_v4();
uuid_tree.insert(uuid.as_bytes(), link.as_str())?;
let mut old = link_tree.get(link.as_str())?;
loop {
let new = old
.clone()
.and_then(|ivec| serde_json::from_slice(&ivec).ok())
.or_else(|| Some(StateValue::new()))
.map(|mut bts: StateValue| {
bts.insert(uuid);
bts
})
.and_then(|bts| serde_json::to_vec(&bts).ok());
match link_tree.compare_and_swap(link.to_string(), old, new)? {
Ok(_) => return Ok(uuid),
Err(CompareAndSwapError {
current,
proposed: _,
}) => {
old = current;
}
}
}
})
.await
.expect("db panicked")
}
async fn get_link(&self, uuid: Uuid) -> sled::Result<Option<Url>> {
let uuid_tree = self.uuid_tree.clone();
web::block(move || {
Ok(uuid_tree
.get(uuid.as_bytes())?
.and_then(|url| String::from_utf8_lossy(&url).parse().ok()))
})
.await
.expect("db panicked")
}
}
#[derive(serde::Deserialize)]
struct Link {
url: Url,
}
async fn new_link(
Json(Link { url }): Json<Link>,
state: web::Data<State>,
) -> actix_web::Result<HttpResponse> {
let uuid = state
.save_link(url)
.await
.map_err(ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(serde_json::json!({ "uuid": uuid })))
}
async fn link_to(path: Path<Uuid>, state: web::Data<State>) -> actix_web::Result<HttpResponse> {
let url = state
.get_link(path.into_inner())
.await
.map_err(ErrorInternalServerError)?;
if let Some(url) = url {
Ok(HttpResponse::SeeOther()
.insert_header((LOCATION, url.to_string()))
.json(serde_json::json!({ "url": url })))
} else {
Ok(HttpResponse::NotFound().finish())
}
}
#[derive(Debug)]
struct MissingUserAgent;
impl std::fmt::Display for MissingUserAgent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "No User-Agent header provided")
}
}
impl std::error::Error for MissingUserAgent {}
#[derive(Debug)]
struct DisallowedUserAgent;
impl std::fmt::Display for DisallowedUserAgent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "User-Agent header is not acceptable")
}
}
impl std::error::Error for DisallowedUserAgent {}
async fn deny_invalid_agents(
req: ServiceRequest,
next: Next<impl MessageBody>,
) -> actix_web::Result<ServiceResponse<impl MessageBody>> {
let user_agent = req
.headers()
.get("user-agent")
.ok_or_else(|| ErrorBadRequest(MissingUserAgent))?;
let parsed = user_agent.to_str().map_err(ErrorBadRequest)?;
if parsed.to_lowercase().contains("twitter") {
return Err(ErrorBadRequest(DisallowedUserAgent));
}
next.call(req).await
}
#[actix_web::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = Config::new().path("./data/sled-0-34").open()?;
let state = State::build(db)?;
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(state.clone()))
.wrap(from_fn(deny_invalid_agents))
.route("/link", web::post().to(new_link))
.route("/link/{uuid}", web::get().to(link_to))
.service(
spa()
.index_file("./static/index.html")
.static_resources_mount("/static")
.static_resources_location("./static")
.finish(),
)
})
.bind(("0.0.0.0", 8078))?
.run()
.await?;
Ok(())
}

28
server/static/index.html Normal file
View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Doglinks</title>
<script src="/static/main.js"></script>
</head>
<body>
<div id="app">
</div>
<script>
const app = Elm.Main.init({
node: document.getElementById("app"),
flags: window.location.origin,
});
app.ports.copyLink.subscribe(() => {
console.log("copy");
document.querySelector("#copy-doglink").select();
document.execCommand("copy");
app.ports.linkCopied.send(null);
});
</script>
</body>
</html>