Initial doglinks
TODO - style it at all - rate limit
This commit is contained in:
commit
3404b377d3
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
/server/target
|
||||
/server/data
|
||||
/server/static/main.js
|
||||
/client/elm-stuff
|
9
build.sh
Executable file
9
build.sh
Executable 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
28
client/elm.json
Normal 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
103
client/src/CreatePage.elm
Normal 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
152
client/src/Main.elm
Normal 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
81
client/src/ShowPage.elm
Normal 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
1529
server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
15
server/Cargo.toml
Normal file
15
server/Cargo.toml
Normal 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
184
server/src/main.rs
Normal 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
28
server/static/index.html
Normal 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>
|
Loading…
Reference in a new issue