Compare commits
No commits in common. "40ee0c16d7afdabbfe8bd430d645203aab28d1d4" and "70a315165f90a6350a11bddd53b81233d4114ca3" have entirely different histories.
40ee0c16d7
...
70a315165f
34 changed files with 15778 additions and 107 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -116,3 +116,8 @@ dist
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
static/
|
||||||
|
|
||||||
|
.idea
|
||||||
|
|
||||||
|
elm-stuff/
|
||||||
|
|
29
elm-deprecated/elm.json
Normal file
29
elm-deprecated/elm.json
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"type": "application",
|
||||||
|
"source-directories": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"elm-version": "0.19.1",
|
||||||
|
"dependencies": {
|
||||||
|
"direct": {
|
||||||
|
"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/time": "1.0.0",
|
||||||
|
"elm/url": "1.0.0",
|
||||||
|
"f0i/iso8601": "1.1.2",
|
||||||
|
"mdgriffith/elm-ui": "1.1.8"
|
||||||
|
},
|
||||||
|
"indirect": {
|
||||||
|
"elm/bytes": "1.0.8",
|
||||||
|
"elm/file": "1.0.5",
|
||||||
|
"elm/virtual-dom": "1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test-dependencies": {
|
||||||
|
"direct": {},
|
||||||
|
"indirect": {}
|
||||||
|
}
|
||||||
|
}
|
14025
elm-deprecated/index.html
Normal file
14025
elm-deprecated/index.html
Normal file
File diff suppressed because it is too large
Load diff
9
elm-deprecated/src/About.elm
Normal file
9
elm-deprecated/src/About.elm
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
module About exposing (..)
|
||||||
|
|
||||||
|
import Element exposing (..)
|
||||||
|
import Model exposing (..)
|
||||||
|
|
||||||
|
|
||||||
|
getPage : Model -> Element Msg
|
||||||
|
getPage _ =
|
||||||
|
text "About page."
|
9
elm-deprecated/src/Account.elm
Normal file
9
elm-deprecated/src/Account.elm
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
module Account exposing (..)
|
||||||
|
|
||||||
|
import Element exposing (..)
|
||||||
|
import Model exposing (..)
|
||||||
|
|
||||||
|
|
||||||
|
getPage : Model -> Element Msg
|
||||||
|
getPage _ =
|
||||||
|
text "Account page."
|
290
elm-deprecated/src/Api.elm
Normal file
290
elm-deprecated/src/Api.elm
Normal file
|
@ -0,0 +1,290 @@
|
||||||
|
port module Api exposing (Cred, addServerError, application, decodeErrors, delete, get, login, logout, post, put, register, settings, storeCredWith, username, viewerChanges)
|
||||||
|
|
||||||
|
{-| This module is responsible for communicating to the Conduit API.
|
||||||
|
It exposes an opaque Endpoint type which is guaranteed to point to the correct URL.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api.Endpoint as Endpoint exposing (Endpoint)
|
||||||
|
import Avatar exposing (Avatar)
|
||||||
|
import Browser
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Http exposing (Body, Expect)
|
||||||
|
import Json.Decode as Decode exposing (Decoder, Value, decodeString, field, string)
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Url exposing (Url)
|
||||||
|
import Username exposing (Username)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CRED
|
||||||
|
|
||||||
|
|
||||||
|
{-| The authentication credentials for the Viewer (that is, the currently logged-in user.)
|
||||||
|
This includes:
|
||||||
|
|
||||||
|
- The cred's Username
|
||||||
|
- The cred's authentication token
|
||||||
|
By design, there is no way to access the token directly as a String.
|
||||||
|
It can be encoded for persistence, and it can be added to a header
|
||||||
|
to a HttpBuilder for a request, but that's it.
|
||||||
|
This token should never be rendered to the end user, and with this API, it
|
||||||
|
can't be!
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Cred
|
||||||
|
= Cred Username String
|
||||||
|
|
||||||
|
|
||||||
|
username : Cred -> Username
|
||||||
|
username (Cred val _) =
|
||||||
|
val
|
||||||
|
|
||||||
|
|
||||||
|
credHeader : Cred -> Http.Header
|
||||||
|
credHeader (Cred _ str) =
|
||||||
|
Http.header "authorization" ("Token " ++ str)
|
||||||
|
|
||||||
|
|
||||||
|
{-| It's important that this is never exposed!
|
||||||
|
We expose `login` and `application` instead, so we can be certain that if anyone
|
||||||
|
ever has access to a `Cred` value, it came from either the login API endpoint
|
||||||
|
or was passed in via flags.
|
||||||
|
-}
|
||||||
|
credDecoder : Decoder Cred
|
||||||
|
credDecoder =
|
||||||
|
Decode.succeed Cred
|
||||||
|
|> field "username" Username.decoder
|
||||||
|
|> field "token" Decode.string
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- PERSISTENCE
|
||||||
|
|
||||||
|
|
||||||
|
decode : Decoder (Cred -> viewer) -> Value -> Result Decode.Error viewer
|
||||||
|
decode decoder value =
|
||||||
|
-- It's stored in localStorage as a JSON String;
|
||||||
|
-- first decode the Value as a String, then
|
||||||
|
-- decode that String as JSON.
|
||||||
|
Decode.decodeValue Decode.string value
|
||||||
|
|> Result.andThen (\str -> Decode.decodeString (Decode.field "user" (decoderFromCred decoder)) str)
|
||||||
|
|
||||||
|
|
||||||
|
port onStoreChange : (Value -> msg) -> Sub msg
|
||||||
|
|
||||||
|
|
||||||
|
viewerChanges : (Maybe viewer -> msg) -> Decoder (Cred -> viewer) -> Sub msg
|
||||||
|
viewerChanges toMsg decoder =
|
||||||
|
onStoreChange (\value -> toMsg (decodeFromChange decoder value))
|
||||||
|
|
||||||
|
|
||||||
|
decodeFromChange : Decoder (Cred -> viewer) -> Value -> Maybe viewer
|
||||||
|
decodeFromChange viewerDecoder val =
|
||||||
|
-- It's stored in localStorage as a JSON String;
|
||||||
|
-- first decode the Value as a String, then
|
||||||
|
-- decode that String as JSON.
|
||||||
|
Decode.decodeValue (storageDecoder viewerDecoder) val
|
||||||
|
|> Result.toMaybe
|
||||||
|
|
||||||
|
|
||||||
|
storeCredWith : Cred -> Avatar -> Cmd msg
|
||||||
|
storeCredWith (Cred uname token) avatar =
|
||||||
|
let
|
||||||
|
json =
|
||||||
|
Encode.object
|
||||||
|
[ ( "user"
|
||||||
|
, Encode.object
|
||||||
|
[ ( "username", Username.encode uname )
|
||||||
|
, ( "token", Encode.string token )
|
||||||
|
, ( "image", Avatar.encode avatar )
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
in
|
||||||
|
storeCache (Just json)
|
||||||
|
|
||||||
|
|
||||||
|
logout : Cmd msg
|
||||||
|
logout =
|
||||||
|
storeCache Nothing
|
||||||
|
|
||||||
|
|
||||||
|
port storeCache : Maybe Value -> Cmd msg
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
-- APPLICATION
|
||||||
|
|
||||||
|
|
||||||
|
application :
|
||||||
|
Decoder (Cred -> viewer)
|
||||||
|
->
|
||||||
|
{ init : Maybe viewer -> Url -> Nav.Key -> ( model, Cmd msg )
|
||||||
|
, onUrlChange : Url -> msg
|
||||||
|
, onUrlRequest : Browser.UrlRequest -> msg
|
||||||
|
, subscriptions : model -> Sub msg
|
||||||
|
, update : msg -> model -> ( model, Cmd msg )
|
||||||
|
, view : model -> Browser.Document msg
|
||||||
|
}
|
||||||
|
-> Program Value model msg
|
||||||
|
application viewerDecoder config =
|
||||||
|
let
|
||||||
|
init flags url navKey =
|
||||||
|
let
|
||||||
|
maybeViewer =
|
||||||
|
Decode.decodeValue Decode.string flags
|
||||||
|
|> Result.andThen (Decode.decodeString (storageDecoder viewerDecoder))
|
||||||
|
|> Result.toMaybe
|
||||||
|
in
|
||||||
|
config.init maybeViewer url navKey
|
||||||
|
in
|
||||||
|
Browser.application
|
||||||
|
{ init = init
|
||||||
|
, onUrlChange = config.onUrlChange
|
||||||
|
, onUrlRequest = config.onUrlRequest
|
||||||
|
, subscriptions = config.subscriptions
|
||||||
|
, update = config.update
|
||||||
|
, view = config.view
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
storageDecoder : Decoder (Cred -> viewer) -> Decoder viewer
|
||||||
|
storageDecoder viewerDecoder =
|
||||||
|
Decode.field "user" (decoderFromCred viewerDecoder)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
get : Endpoint -> Maybe Cred -> Decoder a -> Cmd a
|
||||||
|
get url maybeCred decoder =
|
||||||
|
Endpoint.request
|
||||||
|
{ method = "GET"
|
||||||
|
, url = url
|
||||||
|
, expect = Http.expectJson decoder
|
||||||
|
, headers =
|
||||||
|
case maybeCred of
|
||||||
|
Just cred ->
|
||||||
|
[ credHeader cred ]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[]
|
||||||
|
, body = Http.emptyBody
|
||||||
|
, timeout = Nothing
|
||||||
|
, withCredentials = False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
put : Endpoint -> Cred -> Body -> Decoder a -> Cmd a
|
||||||
|
put url cred body decoder =
|
||||||
|
Endpoint.request
|
||||||
|
{ method = "PUT"
|
||||||
|
, url = url
|
||||||
|
, expect = Http.expectJson decoder
|
||||||
|
, headers = [ credHeader cred ]
|
||||||
|
, body = body
|
||||||
|
, timeout = Nothing
|
||||||
|
, withCredentials = False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
post : Endpoint -> Maybe Cred -> Body -> Decoder a -> Cmd a
|
||||||
|
post url maybeCred body decoder =
|
||||||
|
Endpoint.request
|
||||||
|
{ method = "POST"
|
||||||
|
, url = url
|
||||||
|
, expect = Http.expectJson decoder
|
||||||
|
, headers =
|
||||||
|
case maybeCred of
|
||||||
|
Just cred ->
|
||||||
|
[ credHeader cred ]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[]
|
||||||
|
, body = body
|
||||||
|
, timeout = Nothing
|
||||||
|
, withCredentials = False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
delete : Endpoint -> Cred -> Body -> Decoder a -> Cmd a
|
||||||
|
delete url cred body decoder =
|
||||||
|
Endpoint.request
|
||||||
|
{ method = "DELETE"
|
||||||
|
, url = url
|
||||||
|
, expect = Http.expectJson decoder
|
||||||
|
, headers = [ credHeader cred ]
|
||||||
|
, body = body
|
||||||
|
, timeout = Nothing
|
||||||
|
, withCredentials = False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
login : Http.Body -> Decoder (Cred -> a) -> Cmd a
|
||||||
|
login body decoder =
|
||||||
|
post Endpoint.login Nothing body (Decode.field "user" (decoderFromCred decoder))
|
||||||
|
|
||||||
|
|
||||||
|
register : Http.Body -> Decoder (Cred -> a) -> Cmd a
|
||||||
|
register body decoder =
|
||||||
|
post Endpoint.users Nothing body (Decode.field "user" (decoderFromCred decoder))
|
||||||
|
|
||||||
|
|
||||||
|
settings : Cred -> Http.Body -> Decoder (Cred -> a) -> Cmd a
|
||||||
|
settings cred body decoder =
|
||||||
|
put Endpoint.user cred body (Decode.field "user" (decoderFromCred decoder))
|
||||||
|
|
||||||
|
|
||||||
|
decoderFromCred : Decoder (Cred -> a) -> Decoder a
|
||||||
|
decoderFromCred decoder =
|
||||||
|
Decode.map2 (\fromCred cred -> fromCred cred)
|
||||||
|
decoder
|
||||||
|
credDecoder
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- ERRORS
|
||||||
|
|
||||||
|
|
||||||
|
addServerError : List String -> List String
|
||||||
|
addServerError list =
|
||||||
|
"Server error" :: list
|
||||||
|
|
||||||
|
|
||||||
|
{-| Many API endpoints include an "errors" field in their BadStatus responses.
|
||||||
|
-}
|
||||||
|
decodeErrors : Http.Error -> List String
|
||||||
|
decodeErrors error =
|
||||||
|
case error of
|
||||||
|
Http.BadStatus errid ->
|
||||||
|
[ Int.toString errid ]
|
||||||
|
|
||||||
|
err ->
|
||||||
|
[ "Server error" ]
|
||||||
|
|
||||||
|
|
||||||
|
errorsDecoder : Decoder (List String)
|
||||||
|
errorsDecoder =
|
||||||
|
Decode.keyValuePairs (Decode.list Decode.string)
|
||||||
|
|> Decode.map (List.concatMap fromPair)
|
||||||
|
|
||||||
|
|
||||||
|
fromPair : ( String, List String ) -> List String
|
||||||
|
fromPair ( field, errors ) =
|
||||||
|
List.map (\error -> field ++ " " ++ error) errors
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- LOCALSTORAGE KEYS
|
||||||
|
|
||||||
|
|
||||||
|
cacheStorageKey : String
|
||||||
|
cacheStorageKey =
|
||||||
|
"cache"
|
||||||
|
|
||||||
|
|
||||||
|
credStorageKey : String
|
||||||
|
credStorageKey =
|
||||||
|
"cred"
|
99
elm-deprecated/src/Api/Endpoint.elm
Normal file
99
elm-deprecated/src/Api/Endpoint.elm
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
module Api.Endpoint exposing (Endpoint, login, request, tags, todo, todoList, user, users)
|
||||||
|
|
||||||
|
import Http
|
||||||
|
import Todo.UUID as UUID exposing (UUID)
|
||||||
|
import Url.Builder exposing (QueryParameter)
|
||||||
|
import Username exposing (Username)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Http.request, except it takes an Endpoint instead of a Url.
|
||||||
|
-}
|
||||||
|
request :
|
||||||
|
{ body : Http.Body
|
||||||
|
, expect : Http.Expect a
|
||||||
|
, headers : List Http.Header
|
||||||
|
, method : String
|
||||||
|
, timeout : Maybe Float
|
||||||
|
, url : Endpoint
|
||||||
|
, tracker : Maybe String
|
||||||
|
}
|
||||||
|
-> Cmd a
|
||||||
|
request config =
|
||||||
|
Http.request
|
||||||
|
{ body = config.body
|
||||||
|
, expect = config.expect
|
||||||
|
, headers = config.headers
|
||||||
|
, method = config.method
|
||||||
|
, timeout = config.timeout
|
||||||
|
, url = unwrap config.url
|
||||||
|
, tracker = config.tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
{-| Get a URL to the Conduit API.
|
||||||
|
This is not publicly exposed, because we want to make sure the only way to get one of these URLs is from this module.
|
||||||
|
-}
|
||||||
|
type Endpoint
|
||||||
|
= Endpoint String
|
||||||
|
|
||||||
|
|
||||||
|
unwrap : Endpoint -> String
|
||||||
|
unwrap (Endpoint str) =
|
||||||
|
str
|
||||||
|
|
||||||
|
|
||||||
|
url : List String -> List QueryParameter -> Endpoint
|
||||||
|
url paths queryParams =
|
||||||
|
-- NOTE: Url.Builder takes care of percent-encoding special URL characters.
|
||||||
|
-- See https://package.elm-lang.org/packages/elm/url/latest/Url#percentEncode
|
||||||
|
Url.Builder.crossOrigin "https://conduit.productionready.io"
|
||||||
|
("api" :: paths)
|
||||||
|
queryParams
|
||||||
|
|> Endpoint
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- ENDPOINTS
|
||||||
|
|
||||||
|
|
||||||
|
login : Endpoint
|
||||||
|
login =
|
||||||
|
url [ "users", "login" ] []
|
||||||
|
|
||||||
|
|
||||||
|
user : Endpoint
|
||||||
|
user =
|
||||||
|
url [ "user" ] []
|
||||||
|
|
||||||
|
|
||||||
|
users : Endpoint
|
||||||
|
users =
|
||||||
|
url [ "users" ] []
|
||||||
|
|
||||||
|
|
||||||
|
follow : Username -> Endpoint
|
||||||
|
follow uname =
|
||||||
|
url [ "profiles", Username.toString uname, "follow" ] []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- ARTICLE ENDPOINTS
|
||||||
|
|
||||||
|
|
||||||
|
todo : UUID -> Endpoint
|
||||||
|
todo uuid =
|
||||||
|
url [ "articles", UUID.toString uuid ] []
|
||||||
|
|
||||||
|
|
||||||
|
todoList : List QueryParameter -> Endpoint
|
||||||
|
todoList params =
|
||||||
|
url [ "articles" ] params
|
||||||
|
|
||||||
|
|
||||||
|
tags : Endpoint
|
||||||
|
tags =
|
||||||
|
url [ "tags" ] []
|
46
elm-deprecated/src/Asset.elm
Normal file
46
elm-deprecated/src/Asset.elm
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
module Asset exposing (Image, defaultAvatar, error, loading, src)
|
||||||
|
|
||||||
|
{-| Assets, such as images, videos, and audio. (We only have images for now.)
|
||||||
|
We should never expose asset URLs directly; this module should be in charge of
|
||||||
|
all of them. One source of truth!
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Html exposing (Attribute, Html)
|
||||||
|
import Html.Attributes as Attr
|
||||||
|
|
||||||
|
|
||||||
|
type Image
|
||||||
|
= Image String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- IMAGES
|
||||||
|
|
||||||
|
|
||||||
|
error : Image
|
||||||
|
error =
|
||||||
|
image "error.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
loading : Image
|
||||||
|
loading =
|
||||||
|
image "loading.svg"
|
||||||
|
|
||||||
|
|
||||||
|
defaultAvatar : Image
|
||||||
|
defaultAvatar =
|
||||||
|
image "smiley-cyrus.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
image : String -> Image
|
||||||
|
image filename =
|
||||||
|
Image ("/assets/images/" ++ filename)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- USING IMAGES
|
||||||
|
|
||||||
|
|
||||||
|
src : Image -> Attribute msg
|
||||||
|
src (Image url) =
|
||||||
|
Attr.src url
|
56
elm-deprecated/src/Avatar.elm
Normal file
56
elm-deprecated/src/Avatar.elm
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
module Avatar exposing (Avatar, decoder, encode, src, toMaybeString)
|
||||||
|
|
||||||
|
import Asset
|
||||||
|
import Html exposing (Attribute)
|
||||||
|
import Html.Attributes
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Avatar
|
||||||
|
= Avatar (Maybe String)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Avatar
|
||||||
|
decoder =
|
||||||
|
Decode.map Avatar (Decode.nullable Decode.string)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
encode : Avatar -> Value
|
||||||
|
encode (Avatar maybeUrl) =
|
||||||
|
case maybeUrl of
|
||||||
|
Just url ->
|
||||||
|
Encode.string url
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
Encode.null
|
||||||
|
|
||||||
|
|
||||||
|
src : Avatar -> Attribute msg
|
||||||
|
src (Avatar maybeUrl) =
|
||||||
|
case maybeUrl of
|
||||||
|
Nothing ->
|
||||||
|
Asset.src Asset.defaultAvatar
|
||||||
|
|
||||||
|
Just "" ->
|
||||||
|
Asset.src Asset.defaultAvatar
|
||||||
|
|
||||||
|
Just url ->
|
||||||
|
Html.Attributes.src url
|
||||||
|
|
||||||
|
|
||||||
|
toMaybeString : Avatar -> Maybe String
|
||||||
|
toMaybeString (Avatar maybeUrl) =
|
||||||
|
maybeUrl
|
31
elm-deprecated/src/CommonElements.elm
Normal file
31
elm-deprecated/src/CommonElements.elm
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
-- this is a list of common styled elements
|
||||||
|
|
||||||
|
|
||||||
|
module CommonElements exposing (..)
|
||||||
|
|
||||||
|
import Element exposing (..)
|
||||||
|
import Element.Font as Font
|
||||||
|
|
||||||
|
|
||||||
|
bolded : String -> Element msg
|
||||||
|
bolded content =
|
||||||
|
el
|
||||||
|
[ Font.bold ]
|
||||||
|
(text content)
|
||||||
|
|
||||||
|
|
||||||
|
namedLink : String -> String -> Element msg
|
||||||
|
namedLink path name =
|
||||||
|
link []
|
||||||
|
{ url = "/#" ++ path
|
||||||
|
, label = text name
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
debugLog : a -> a
|
||||||
|
debugLog value =
|
||||||
|
let
|
||||||
|
_ =
|
||||||
|
Debug.log "value: " value
|
||||||
|
in
|
||||||
|
value
|
38
elm-deprecated/src/Email.elm
Normal file
38
elm-deprecated/src/Email.elm
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
module Email exposing (Email, decoder, encode, toString)
|
||||||
|
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
|
||||||
|
|
||||||
|
{-| An email address.
|
||||||
|
Having this as a custom type that's separate from String makes certain
|
||||||
|
mistakes impossible. Consider this function:
|
||||||
|
updateEmailAddress : Email -> String -> Http.Request
|
||||||
|
updateEmailAddress email password = ...
|
||||||
|
(The server needs your password to confirm that you should be allowed
|
||||||
|
to update the email address.)
|
||||||
|
Because Email is not a type alias for String, but is instead a separate
|
||||||
|
custom type, it is now impossible to mix up the argument order of the
|
||||||
|
email and the password. If we do, it won't compile!
|
||||||
|
If Email were instead defined as `type alias Email = String`, we could
|
||||||
|
call updateEmailAddress password email and it would compile (and never
|
||||||
|
work properly).
|
||||||
|
This way, we make it impossible for a bug like that to compile!
|
||||||
|
-}
|
||||||
|
type Email
|
||||||
|
= Email String
|
||||||
|
|
||||||
|
|
||||||
|
toString : Email -> String
|
||||||
|
toString (Email str) =
|
||||||
|
str
|
||||||
|
|
||||||
|
|
||||||
|
encode : Email -> Value
|
||||||
|
encode (Email str) =
|
||||||
|
Encode.string str
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Email
|
||||||
|
decoder =
|
||||||
|
Decode.map Email Decode.string
|
9
elm-deprecated/src/Home.elm
Normal file
9
elm-deprecated/src/Home.elm
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
module Home exposing (..)
|
||||||
|
|
||||||
|
import Element exposing (..)
|
||||||
|
import Model exposing (..)
|
||||||
|
|
||||||
|
|
||||||
|
getPage : Model -> Element Msg
|
||||||
|
getPage _ =
|
||||||
|
text "Home page."
|
40
elm-deprecated/src/Login.elm
Normal file
40
elm-deprecated/src/Login.elm
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
module Login exposing (..)
|
||||||
|
|
||||||
|
import CommonElements exposing (..)
|
||||||
|
import Element exposing (..)
|
||||||
|
import Element.Input as Input
|
||||||
|
import Model exposing (..)
|
||||||
|
|
||||||
|
|
||||||
|
getPage : Model -> Element Msg
|
||||||
|
getPage model =
|
||||||
|
column []
|
||||||
|
[ loginBox model
|
||||||
|
, namedLink "/signup" "Sign Up"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
loginBox : Model -> Element Msg
|
||||||
|
loginBox model =
|
||||||
|
column []
|
||||||
|
[ text "Log in"
|
||||||
|
, Input.email
|
||||||
|
[ Input.focusedOnLoad
|
||||||
|
]
|
||||||
|
{ onChange = CredentialsChange "loginUsername"
|
||||||
|
, text = Maybe.withDefault "" model.storage.loginUsername
|
||||||
|
, placeholder = Just (Input.placeholder [] (text ""))
|
||||||
|
, label = Input.labelHidden "email"
|
||||||
|
}
|
||||||
|
, Input.currentPassword []
|
||||||
|
{ onChange = CredentialsChange "loginPassword"
|
||||||
|
, text = Maybe.withDefault "" model.storage.loginPassword
|
||||||
|
, placeholder = Just (Input.placeholder [] (text ""))
|
||||||
|
, label = Input.labelHidden "password"
|
||||||
|
, show = False
|
||||||
|
}
|
||||||
|
, Input.button []
|
||||||
|
{ onPress = Just SubmitLogin
|
||||||
|
, label = text "Log In"
|
||||||
|
}
|
||||||
|
]
|
178
elm-deprecated/src/Main.elm
Normal file
178
elm-deprecated/src/Main.elm
Normal file
|
@ -0,0 +1,178 @@
|
||||||
|
module Main exposing (..)
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import CommonElements exposing (..)
|
||||||
|
import Element exposing (..)
|
||||||
|
import NavRow exposing (..)
|
||||||
|
import PageState exposing (..)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Url
|
||||||
|
import Viewer exposing (..)
|
||||||
|
|
||||||
|
|
||||||
|
type Model
|
||||||
|
= Redirect Session
|
||||||
|
| NotFound Session
|
||||||
|
| About Session
|
||||||
|
| Home Home.Model
|
||||||
|
| Account Account.Model
|
||||||
|
| Login Login.Model
|
||||||
|
| Signup Signup.Model
|
||||||
|
| Todo Todo.Model
|
||||||
|
| Editor (Maybe UUID) Editor.Model
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= UrlChange Url.Url
|
||||||
|
| Request Browser.UrlRequest
|
||||||
|
| GotHomeMsg Home.Msg
|
||||||
|
| GotAccountMsg Account.Msg
|
||||||
|
| GotLoginMsg Login.Msg
|
||||||
|
| GotSignupMsg Signup.Msg
|
||||||
|
| GotEditorMsg Editor.Msg
|
||||||
|
| GotTodoMsg Todo.Msg
|
||||||
|
| GotSession Session
|
||||||
|
|
||||||
|
|
||||||
|
init : Maybe Viewer -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||||
|
init maybeViewer url key =
|
||||||
|
changeRouteTo (Route.fromUrl url)
|
||||||
|
(Redirect (Session.fromViewer navKey maybeViewer))
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession page =
|
||||||
|
case page of
|
||||||
|
Redirect session ->
|
||||||
|
session
|
||||||
|
|
||||||
|
NotFound session ->
|
||||||
|
session
|
||||||
|
|
||||||
|
About session ->
|
||||||
|
session
|
||||||
|
|
||||||
|
Home home ->
|
||||||
|
Home.toSession home
|
||||||
|
|
||||||
|
Account settings ->
|
||||||
|
Settings.toSession settings
|
||||||
|
|
||||||
|
Login login ->
|
||||||
|
Login.toSession login
|
||||||
|
|
||||||
|
Signup signup ->
|
||||||
|
Signup.toSession signup
|
||||||
|
|
||||||
|
Todo todo ->
|
||||||
|
Todo.toSession todo
|
||||||
|
|
||||||
|
Editor _ editor ->
|
||||||
|
Editor.toSession editor
|
||||||
|
|
||||||
|
|
||||||
|
changeRouteTo : Maybe Route -> Model -> ( Model, Cmd Msg )
|
||||||
|
changeRouteTo maybeRoute model =
|
||||||
|
let
|
||||||
|
session =
|
||||||
|
toSession model
|
||||||
|
in
|
||||||
|
case maybeRoute of
|
||||||
|
Nothing ->
|
||||||
|
( NotFound session, Cmd.none )
|
||||||
|
|
||||||
|
Just Route.Root ->
|
||||||
|
( model, Route.replaceUrl (Session.navKey session) Route.Home )
|
||||||
|
|
||||||
|
Just Route.Logout ->
|
||||||
|
( model, Api.logout )
|
||||||
|
|
||||||
|
Just Route.NewTodo ->
|
||||||
|
Editor.initNew session
|
||||||
|
|> updateWith (Editor Nothing) GotEditorMsg model
|
||||||
|
|
||||||
|
Just (Route.EditTodo slug) ->
|
||||||
|
Editor.initEdit session slug
|
||||||
|
|> updateWith (Editor (Just slug)) GotEditorMsg model
|
||||||
|
|
||||||
|
Just Route.Account ->
|
||||||
|
Account.init session
|
||||||
|
|> updateWith Account GotAccountMsg model
|
||||||
|
|
||||||
|
Just Route.Home ->
|
||||||
|
Home.init session
|
||||||
|
|> updateWith Home GotHomeMsg model
|
||||||
|
|
||||||
|
Just Route.Login ->
|
||||||
|
Login.init session
|
||||||
|
|> updateWith Login GotLoginMsg model
|
||||||
|
|
||||||
|
Just Route.Signup ->
|
||||||
|
Signup.init session
|
||||||
|
|> updateWith Signup GotSignupMsg model
|
||||||
|
|
||||||
|
Just (Route.Profile username) ->
|
||||||
|
Profile.init session username
|
||||||
|
|> updateWith (Profile username) GotProfileMsg model
|
||||||
|
|
||||||
|
Just (Route.Todo slug) ->
|
||||||
|
Todo.init session slug
|
||||||
|
|> updateWith Todo GotArticleMsg model
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case ( msg, model ) of
|
||||||
|
( _, _ ) ->
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
updateWith : (subModel -> Model) -> (subMsg -> Msg) -> Model -> ( subModel, Cmd subMsg ) -> ( Model, Cmd Msg )
|
||||||
|
updateWith toModel toMsg model ( subModel, subCmd ) =
|
||||||
|
( toModel subModel
|
||||||
|
, Cmd.map toMsg subCmd
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- view takes current application state and is responsible
|
||||||
|
-- for rendering the document based on it
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Browser.Document Msg
|
||||||
|
view model =
|
||||||
|
{ title = "Elm App Test"
|
||||||
|
, body =
|
||||||
|
[ layout [ height fill ]
|
||||||
|
(column [ width fill ]
|
||||||
|
[ getNavRow model
|
||||||
|
, getMainContent model
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- subscriptions takes the current state and
|
||||||
|
-- maybe returns the subscriptions based on it (?)
|
||||||
|
-- might be used to subscribe to different events based on
|
||||||
|
-- the url, etc.
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions _ =
|
||||||
|
Sub.none
|
||||||
|
|
||||||
|
|
||||||
|
main : Program Value Model Msg
|
||||||
|
main =
|
||||||
|
Api.application Viewer.decoder
|
||||||
|
{ init = init
|
||||||
|
, view = view
|
||||||
|
, update = update
|
||||||
|
, subscriptions = subscriptions
|
||||||
|
, onUrlChange = UrlChange -- these are actual types from Msg
|
||||||
|
, onUrlRequest = Request
|
||||||
|
}
|
57
elm-deprecated/src/NavRow.elm
Normal file
57
elm-deprecated/src/NavRow.elm
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
module NavRow exposing (..)
|
||||||
|
|
||||||
|
import CommonElements exposing (..)
|
||||||
|
import Element exposing (..)
|
||||||
|
import Element.Region as Region
|
||||||
|
import Model exposing (..)
|
||||||
|
import Request exposing (..)
|
||||||
|
import Url
|
||||||
|
|
||||||
|
|
||||||
|
getNavRow : Model -> Element Msg
|
||||||
|
getNavRow model =
|
||||||
|
row
|
||||||
|
[ Region.navigation
|
||||||
|
|
||||||
|
--, explain Debug.todo
|
||||||
|
, paddingXY 10 5
|
||||||
|
, spacing 10
|
||||||
|
, width fill
|
||||||
|
]
|
||||||
|
[ namedLink "/" "TODOAPP"
|
||||||
|
, getDebugInfo model
|
||||||
|
, getCurrentUser model
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- temp function to get current page url
|
||||||
|
-- and links in case shit breaks
|
||||||
|
|
||||||
|
|
||||||
|
getDebugInfo : Model -> Element Msg
|
||||||
|
getDebugInfo model =
|
||||||
|
row
|
||||||
|
[ centerX ]
|
||||||
|
[ text "Current URL: "
|
||||||
|
, bolded (Url.toString model.url)
|
||||||
|
, column []
|
||||||
|
[ namedLink "/" "root"
|
||||||
|
, namedLink "/login" "login"
|
||||||
|
, namedLink "/signup" "signup"
|
||||||
|
, namedLink "/account" "account"
|
||||||
|
, namedLink "/about" "about"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
getCurrentUser : Model -> Element Msg
|
||||||
|
getCurrentUser model =
|
||||||
|
el []
|
||||||
|
(case model.user of
|
||||||
|
Just user ->
|
||||||
|
namedLink "/account" user.email
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
namedLink "/login" "Log In"
|
||||||
|
)
|
66
elm-deprecated/src/PageState.elm
Normal file
66
elm-deprecated/src/PageState.elm
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
module PageState exposing (getMainContent)
|
||||||
|
|
||||||
|
import About as AboutPage
|
||||||
|
import Account as AccountPage
|
||||||
|
import Element exposing (..)
|
||||||
|
import Home as HomePage
|
||||||
|
import Login as LoginPage
|
||||||
|
import Model exposing (..)
|
||||||
|
import Signup as SignupPage
|
||||||
|
import Url
|
||||||
|
import Url.Parser as Parser exposing (..)
|
||||||
|
|
||||||
|
|
||||||
|
type Route
|
||||||
|
= About
|
||||||
|
| Account
|
||||||
|
| Home
|
||||||
|
| Login
|
||||||
|
| Signup
|
||||||
|
| NotFound
|
||||||
|
|
||||||
|
|
||||||
|
getMainContent : Model -> Element Msg
|
||||||
|
getMainContent model =
|
||||||
|
el []
|
||||||
|
(case consume (toRoute model.url) of
|
||||||
|
About ->
|
||||||
|
AboutPage.getPage model
|
||||||
|
|
||||||
|
Account ->
|
||||||
|
AccountPage.getPage model
|
||||||
|
|
||||||
|
Home ->
|
||||||
|
HomePage.getPage model
|
||||||
|
|
||||||
|
Login ->
|
||||||
|
LoginPage.getPage model
|
||||||
|
|
||||||
|
Signup ->
|
||||||
|
SignupPage.getPage model
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
text "Page not found."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
routeParser : Parser (Route -> a) a
|
||||||
|
routeParser =
|
||||||
|
oneOf
|
||||||
|
[ Parser.map Home top
|
||||||
|
, Parser.map Account (s "account")
|
||||||
|
, Parser.map About (s "about")
|
||||||
|
, Parser.map Login (s "login")
|
||||||
|
, Parser.map Signup (s "signup")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
toRoute : Url.Url -> Maybe Route
|
||||||
|
toRoute url =
|
||||||
|
{ url | path = Maybe.withDefault "" url.fragment, fragment = Nothing }
|
||||||
|
|> Parser.parse routeParser
|
||||||
|
|
||||||
|
|
||||||
|
consume : Maybe Route -> Route
|
||||||
|
consume route =
|
||||||
|
Maybe.withDefault NotFound route
|
98
elm-deprecated/src/Route.elm
Normal file
98
elm-deprecated/src/Route.elm
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
module Route exposing (Route(..), fromUrl, href, replaceUrl)
|
||||||
|
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (Attribute)
|
||||||
|
import Html.Attributes as Attr
|
||||||
|
import Todo.UUID as UUID
|
||||||
|
import Url exposing (Url)
|
||||||
|
import Url.Parser as Parser exposing ((</>), Parser, oneOf, s, string)
|
||||||
|
import Username exposing (Username)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- ROUTING
|
||||||
|
|
||||||
|
|
||||||
|
type Route
|
||||||
|
= Home
|
||||||
|
| Login
|
||||||
|
| Logout
|
||||||
|
| Signup
|
||||||
|
| Account
|
||||||
|
| Todo UUID.UUID
|
||||||
|
| NewTodo
|
||||||
|
| EditTodo UUID.UUID
|
||||||
|
|
||||||
|
|
||||||
|
parser : Parser (Route -> a) a
|
||||||
|
parser =
|
||||||
|
oneOf
|
||||||
|
[ Parser.map Home Parser.top
|
||||||
|
, Parser.map Login (s "login")
|
||||||
|
, Parser.map Logout (s "logout")
|
||||||
|
, Parser.map Account (s "account")
|
||||||
|
, Parser.map Signup (s "signup")
|
||||||
|
, Parser.map Todo (s "article" </> UUID.urlParser)
|
||||||
|
, Parser.map NewTodo (s "editor")
|
||||||
|
, Parser.map EditTodo (s "editor" </> UUID.urlParser)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- PUBLIC HELPERS
|
||||||
|
|
||||||
|
|
||||||
|
href : Route -> Attribute msg
|
||||||
|
href targetRoute =
|
||||||
|
Attr.href (routeToString targetRoute)
|
||||||
|
|
||||||
|
|
||||||
|
replaceUrl : Nav.Key -> Route -> Cmd msg
|
||||||
|
replaceUrl key route =
|
||||||
|
Nav.replaceUrl key (routeToString route)
|
||||||
|
|
||||||
|
|
||||||
|
fromUrl : Url -> Maybe Route
|
||||||
|
fromUrl url =
|
||||||
|
-- The RealWorld spec treats the fragment like a path.
|
||||||
|
-- This makes it *literally* the path, so we can proceed
|
||||||
|
-- with parsing as if it had been a normal path all along.
|
||||||
|
{ url | path = Maybe.withDefault "" url.fragment, fragment = Nothing }
|
||||||
|
|> Parser.parse parser
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
routeToString : Route -> String
|
||||||
|
routeToString page =
|
||||||
|
"#/" ++ String.join "/" (routeToPieces page)
|
||||||
|
|
||||||
|
|
||||||
|
routeToPieces : Route -> List String
|
||||||
|
routeToPieces page =
|
||||||
|
case page of
|
||||||
|
Home ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
Login ->
|
||||||
|
[ "login" ]
|
||||||
|
|
||||||
|
Logout ->
|
||||||
|
[ "logout" ]
|
||||||
|
|
||||||
|
Signup ->
|
||||||
|
[ "Signup" ]
|
||||||
|
|
||||||
|
Account ->
|
||||||
|
[ "account" ]
|
||||||
|
|
||||||
|
Todo uuid ->
|
||||||
|
[ "article", UUID.toString uuid ]
|
||||||
|
|
||||||
|
NewTodo ->
|
||||||
|
[ "editor" ]
|
||||||
|
|
||||||
|
EditTodo uuid ->
|
||||||
|
[ "editor", UUID.toString uuid ]
|
75
elm-deprecated/src/Session.elm
Normal file
75
elm-deprecated/src/Session.elm
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
module Session exposing (Session, changes, cred, fromViewer, navKey, viewer)
|
||||||
|
|
||||||
|
import Api exposing (Cred)
|
||||||
|
import Avatar exposing (Avatar)
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Time
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Session
|
||||||
|
= LoggedIn Nav.Key Viewer
|
||||||
|
| Guest Nav.Key
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
viewer : Session -> Maybe Viewer
|
||||||
|
viewer session =
|
||||||
|
case session of
|
||||||
|
LoggedIn _ val ->
|
||||||
|
Just val
|
||||||
|
|
||||||
|
Guest _ ->
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
|
||||||
|
cred : Session -> Maybe Cred
|
||||||
|
cred session =
|
||||||
|
case session of
|
||||||
|
LoggedIn _ val ->
|
||||||
|
Just (Viewer.cred val)
|
||||||
|
|
||||||
|
Guest _ ->
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
|
||||||
|
navKey : Session -> Nav.Key
|
||||||
|
navKey session =
|
||||||
|
case session of
|
||||||
|
LoggedIn key _ ->
|
||||||
|
key
|
||||||
|
|
||||||
|
Guest key ->
|
||||||
|
key
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CHANGES
|
||||||
|
|
||||||
|
|
||||||
|
changes : (Session -> msg) -> Nav.Key -> Sub msg
|
||||||
|
changes toMsg key =
|
||||||
|
Api.viewerChanges (\maybeViewer -> toMsg (fromViewer key maybeViewer)) Viewer.decoder
|
||||||
|
|
||||||
|
|
||||||
|
fromViewer : Nav.Key -> Maybe Viewer -> Session
|
||||||
|
fromViewer key maybeViewer =
|
||||||
|
-- It's stored in localStorage as a JSON String;
|
||||||
|
-- first decode the Value as a String, then
|
||||||
|
-- decode that String as JSON.
|
||||||
|
case maybeViewer of
|
||||||
|
Just viewerVal ->
|
||||||
|
LoggedIn key viewerVal
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
Guest key
|
53
elm-deprecated/src/Signup.elm
Normal file
53
elm-deprecated/src/Signup.elm
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
module Signup exposing (..)
|
||||||
|
|
||||||
|
import CommonElements exposing (..)
|
||||||
|
import Element exposing (..)
|
||||||
|
import Element.Input as Input
|
||||||
|
import Model exposing (..)
|
||||||
|
|
||||||
|
|
||||||
|
getPage : Model -> Element Msg
|
||||||
|
getPage model =
|
||||||
|
el [] (signupBox model)
|
||||||
|
|
||||||
|
|
||||||
|
signupBox : Model -> Element Msg
|
||||||
|
signupBox model =
|
||||||
|
column []
|
||||||
|
[ text "Sign up"
|
||||||
|
, Input.email
|
||||||
|
[ Input.focusedOnLoad
|
||||||
|
]
|
||||||
|
{ onChange = CredentialsChange "loginUsername"
|
||||||
|
, text = Maybe.withDefault "" model.storage.loginUsername
|
||||||
|
, placeholder = Just (Input.placeholder [] (text "email"))
|
||||||
|
, label = Input.labelHidden "email"
|
||||||
|
}
|
||||||
|
, Input.newPassword []
|
||||||
|
{ onChange = CredentialsChange "loginPassword"
|
||||||
|
, text = Maybe.withDefault "" model.storage.loginPassword
|
||||||
|
, placeholder = Just (Input.placeholder [] (text "password"))
|
||||||
|
, label = Input.labelHidden "password"
|
||||||
|
, show = False
|
||||||
|
}
|
||||||
|
, Input.newPassword []
|
||||||
|
{ onChange = CredentialsChange "confirmPassword"
|
||||||
|
, text = Maybe.withDefault "" model.storage.signupConfirmPassword
|
||||||
|
, placeholder = Just (Input.placeholder [] (text "confirm password"))
|
||||||
|
, label = Input.labelHidden "confirm password"
|
||||||
|
, show = False
|
||||||
|
}
|
||||||
|
, Input.button []
|
||||||
|
{ onPress = ensurePasswordMatch model
|
||||||
|
, label = text "Sign Up"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
ensurePasswordMatch : Model -> Maybe Msg
|
||||||
|
ensurePasswordMatch model =
|
||||||
|
if model.storage.signupConfirmPassword == model.storage.loginPassword then
|
||||||
|
Just SubmitSignup
|
||||||
|
|
||||||
|
else
|
||||||
|
Just SignupPasswordMismatch
|
253
elm-deprecated/src/Todo.elm
Normal file
253
elm-deprecated/src/Todo.elm
Normal file
|
@ -0,0 +1,253 @@
|
||||||
|
module Todo exposing (Full, Preview, Todo, author, body, favorite, favoriteButton, fetch, fromPreview, fullDecoder, mapAuthor, metadata, previewDecoder, unfavorite, unfavoriteButton, uuid)
|
||||||
|
|
||||||
|
{-| The interface to the Todo data structure.
|
||||||
|
This includes:
|
||||||
|
|
||||||
|
- The Todo type itself
|
||||||
|
- Ways to make HTTP requests to retrieve and modify Todos
|
||||||
|
- Ways to access information about an Todo
|
||||||
|
- Converting between various types
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api exposing (Cred)
|
||||||
|
import Api.Endpoint as Endpoint
|
||||||
|
import Author exposing (Author)
|
||||||
|
import Element exposing (..)
|
||||||
|
import Http
|
||||||
|
import Iso8601
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Markdown
|
||||||
|
import Time
|
||||||
|
import Todo.Body as Body exposing (Body)
|
||||||
|
import Todo.Tag as Tag exposing (Tag)
|
||||||
|
import Todo.UUID exposing (UUID)
|
||||||
|
import Username as Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
{-| An Todo, optionally with an Todo body.
|
||||||
|
To see the difference between { extraInfo : a } and { extraInfo : Maybe Body },
|
||||||
|
consider the difference between the "view individual Todo" page (which
|
||||||
|
renders one Todo, including its body) and the "Todo feed" -
|
||||||
|
which displays multiple Todos, but without bodies.
|
||||||
|
This definition for `Todo` means we can write:
|
||||||
|
viewTodo : Todo Full -> Html msg
|
||||||
|
viewFeed : List (Todo Preview) -> Html msg
|
||||||
|
This indicates that `viewTodo` requires an Todo _with a `body` present_,
|
||||||
|
wereas `viewFeed` accepts Todos with no bodies. (We could also have written
|
||||||
|
it as `List (Todo a)` to specify that feeds can accept either Todos that
|
||||||
|
have `body` present or not. Either work, given that feeds do not attempt to
|
||||||
|
read the `body` field from Todos.)
|
||||||
|
This is an important distinction, because in Request.Todo, the `feed`
|
||||||
|
function produces `List (Todo Preview)` because the API does not return bodies.
|
||||||
|
Those Todos are useful to the feed, but not to the individual Todo view.
|
||||||
|
-}
|
||||||
|
type Todo a
|
||||||
|
= Todo Internals a
|
||||||
|
|
||||||
|
|
||||||
|
{-| Metadata about the Todo - its title, description, and so on.
|
||||||
|
Importantly, this module's public API exposes a way to read this metadata, but
|
||||||
|
not to alter it. This is read-only information!
|
||||||
|
If we find ourselves using any particular piece of metadata often,
|
||||||
|
for example `title`, we could expose a convenience function like this:
|
||||||
|
Todo.title : Todo a -> String
|
||||||
|
If you like, it's totally reasonable to expose a function like that for every one
|
||||||
|
of these fields!
|
||||||
|
(Okay, to be completely honest, exposing one function per field is how I prefer
|
||||||
|
to do it, and that's how I originally wrote this module. However, I'm aware that
|
||||||
|
this code base has become a common reference point for beginners, and I think it
|
||||||
|
is _extremely important_ that slapping some "getters and setters" on a record
|
||||||
|
does not become a habit for anyone who is getting started with Elm. The whole
|
||||||
|
point of making the Todo type opaque is to create guarantees through
|
||||||
|
_selectively choosing boundaries_ around it. If you aren't selective about
|
||||||
|
where those boundaries are, and instead expose a "getter and setter" for every
|
||||||
|
field in the record, the result is an API with no more guarantees than if you'd
|
||||||
|
exposed the entire record directly! It is so important to me that beginners not
|
||||||
|
fall into the terrible "getters and setters" trap that I've exposed this
|
||||||
|
Metadata record instead of exposing a single function for each of its fields,
|
||||||
|
as I did originally. This record is not a bad way to do it, by any means,
|
||||||
|
but if this seems at odds with <https://youtu.be/x1FU3e0sT1I> - now you know why!
|
||||||
|
)
|
||||||
|
-}
|
||||||
|
type alias Metadata =
|
||||||
|
{ description : String
|
||||||
|
, title : String
|
||||||
|
, tags : List String
|
||||||
|
, createdAt : Time.Posix
|
||||||
|
, favorited : Bool
|
||||||
|
, favoritesCount : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Internals =
|
||||||
|
{ uuid : UUID
|
||||||
|
, author : Author
|
||||||
|
, metadata : Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Preview
|
||||||
|
= Preview
|
||||||
|
|
||||||
|
|
||||||
|
type Full
|
||||||
|
= Full Body
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
author : Todo a -> Author
|
||||||
|
author (Todo internals _) =
|
||||||
|
internals.author
|
||||||
|
|
||||||
|
|
||||||
|
metadata : Todo a -> Metadata
|
||||||
|
metadata (Todo internals _) =
|
||||||
|
internals.metadata
|
||||||
|
|
||||||
|
|
||||||
|
uuid : Todo a -> UUID
|
||||||
|
uuid (Todo internals _) =
|
||||||
|
internals.uuid
|
||||||
|
|
||||||
|
|
||||||
|
body : Todo Full -> Body
|
||||||
|
body (Todo _ (Full extraInfo)) =
|
||||||
|
extraInfo
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
{-| This is the only way you can transform an existing Todo:
|
||||||
|
you can change its author (e.g. to follow or unfollow them).
|
||||||
|
All other Todo data necessarily comes from the server!
|
||||||
|
We can tell this for sure by looking at the types of the exposed functions
|
||||||
|
in this module.
|
||||||
|
-}
|
||||||
|
mapAuthor : (Author -> Author) -> Todo a -> Todo a
|
||||||
|
mapAuthor transform (Todo info extras) =
|
||||||
|
Todo { info | author = transform info.author } extras
|
||||||
|
|
||||||
|
|
||||||
|
fromPreview : Body -> Todo Preview -> Todo Full
|
||||||
|
fromPreview newBody (Todo info Preview) =
|
||||||
|
Todo info (Full newBody)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
previewDecoder : Maybe Cred -> Decoder (Todo Preview)
|
||||||
|
previewDecoder maybeCred =
|
||||||
|
Decode.succeed Todo
|
||||||
|
|> custom (internalsDecoder maybeCred)
|
||||||
|
|> hardcoded Preview
|
||||||
|
|
||||||
|
|
||||||
|
fullDecoder : Maybe Cred -> Decoder (Todo Full)
|
||||||
|
fullDecoder maybeCred =
|
||||||
|
Decode.succeed Todo
|
||||||
|
|> custom (internalsDecoder maybeCred)
|
||||||
|
|> required "body" (Decode.map Full Body.decoder)
|
||||||
|
|
||||||
|
|
||||||
|
internalsDecoder : Maybe Cred -> Decoder Internals
|
||||||
|
internalsDecoder maybeCred =
|
||||||
|
Decode.succeed Internals
|
||||||
|
|> required "uuid" UUID.decoder
|
||||||
|
|> required "author" (Author.decoder maybeCred)
|
||||||
|
|> custom metadataDecoder
|
||||||
|
|
||||||
|
|
||||||
|
metadataDecoder : Decoder Metadata
|
||||||
|
metadataDecoder =
|
||||||
|
Decode.succeed Metadata
|
||||||
|
|> required "description" (Decode.map (Maybe.withDefault "") (Decode.nullable Decode.string))
|
||||||
|
|> required "title" Decode.string
|
||||||
|
|> required "tagList" (Decode.list Decode.string)
|
||||||
|
|> required "createdAt" Iso8601.decoder
|
||||||
|
|> required "favorited" Decode.bool
|
||||||
|
|> required "favoritesCount" Decode.int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SINGLE
|
||||||
|
|
||||||
|
|
||||||
|
fetch : Maybe Cred -> UUID -> Http.Request (Todo Full)
|
||||||
|
fetch maybeCred uuid =
|
||||||
|
Decode.field "Todo" (fullDecoder maybeCred)
|
||||||
|
|> Api.get (Endpoint.Todo uuid) maybeCred
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FAVORITE
|
||||||
|
|
||||||
|
|
||||||
|
favorite : UUID -> Cred -> Http.Request (Todo Preview)
|
||||||
|
favorite uuid cred =
|
||||||
|
Api.post (Endpoint.favorite uuid) (Just cred) Http.emptyBody (faveDecoder cred)
|
||||||
|
|
||||||
|
|
||||||
|
unfavorite : UUID -> Cred -> Http.Request (Todo Preview)
|
||||||
|
unfavorite uuid cred =
|
||||||
|
Api.delete (Endpoint.favorite uuid) cred Http.emptyBody (faveDecoder cred)
|
||||||
|
|
||||||
|
|
||||||
|
faveDecoder : Cred -> Decoder (Todo Preview)
|
||||||
|
faveDecoder cred =
|
||||||
|
Decode.field "Todo" (previewDecoder (Just cred))
|
||||||
|
|
||||||
|
|
||||||
|
{-| This is a "build your own element" API.
|
||||||
|
You pass it some configuration, followed by a `List (Attribute msg)` and a
|
||||||
|
`List (Html msg)`, just like any standard Html element.
|
||||||
|
-}
|
||||||
|
favoriteButton :
|
||||||
|
Cred
|
||||||
|
-> msg
|
||||||
|
-> List (Attribute msg)
|
||||||
|
-> List (Element msg)
|
||||||
|
-> Element msg
|
||||||
|
favoriteButton _ msg attrs kids =
|
||||||
|
toggleFavoriteButton "btn btn-sm btn-outline-primary" msg attrs kids
|
||||||
|
|
||||||
|
|
||||||
|
unfavoriteButton :
|
||||||
|
Cred
|
||||||
|
-> msg
|
||||||
|
-> List (Attribute msg)
|
||||||
|
-> List (Element msg)
|
||||||
|
-> Element msg
|
||||||
|
unfavoriteButton _ msg attrs kids =
|
||||||
|
toggleFavoriteButton "btn btn-sm btn-primary" msg attrs kids
|
||||||
|
|
||||||
|
|
||||||
|
toggleFavoriteButton :
|
||||||
|
String
|
||||||
|
-> msg
|
||||||
|
-> List (Attribute msg)
|
||||||
|
-> List (Element msg)
|
||||||
|
-> Element msg
|
||||||
|
toggleFavoriteButton classStr msg attrs kids =
|
||||||
|
Html.button
|
||||||
|
(class classStr :: onClickStopPropagation msg :: attrs)
|
||||||
|
(i [ class "ion-heart" ] [] :: kids)
|
||||||
|
|
||||||
|
|
||||||
|
onClickStopPropagation : msg -> Attribute msg
|
||||||
|
onClickStopPropagation msg =
|
||||||
|
stopPropagationOn "click"
|
||||||
|
(Decode.succeed ( msg, True ))
|
35
elm-deprecated/src/Todo/UUID.elm
Normal file
35
elm-deprecated/src/Todo/UUID.elm
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
module Todo.UUID exposing (UUID, decoder, toString, urlParser)
|
||||||
|
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Url.Parser exposing (Parser)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type UUID
|
||||||
|
= UUID String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
urlParser : Parser (UUID -> a) a
|
||||||
|
urlParser =
|
||||||
|
Url.Parser.custom "UUID" (\str -> Just (UUID str))
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder UUID
|
||||||
|
decoder =
|
||||||
|
Decode.map UUID Decode.string
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
toString : UUID -> String
|
||||||
|
toString (UUID str) =
|
||||||
|
str
|
47
elm-deprecated/src/Username.elm
Normal file
47
elm-deprecated/src/Username.elm
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
module Username exposing (Username, decoder, encode, toHtml, toString, urlParser)
|
||||||
|
|
||||||
|
import Element exposing (..)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Url.Parser
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Username
|
||||||
|
= Username String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Username
|
||||||
|
decoder =
|
||||||
|
Decode.map Username Decode.string
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
encode : Username -> Value
|
||||||
|
encode (Username username) =
|
||||||
|
Encode.string username
|
||||||
|
|
||||||
|
|
||||||
|
toString : Username -> String
|
||||||
|
toString (Username username) =
|
||||||
|
username
|
||||||
|
|
||||||
|
|
||||||
|
urlParser : Url.Parser.Parser (Username -> a) a
|
||||||
|
urlParser =
|
||||||
|
Url.Parser.custom "USERNAME" (\str -> Just (Username str))
|
||||||
|
|
||||||
|
|
||||||
|
toHtml : Username -> Element msg
|
||||||
|
toHtml (Username username) =
|
||||||
|
text username
|
66
elm-deprecated/src/Viewer.elm
Normal file
66
elm-deprecated/src/Viewer.elm
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
module Viewer exposing (Viewer, avatar, cred, decoder, minPasswordChars, store, username)
|
||||||
|
|
||||||
|
{-| The logged-in user currently viewing this page. It stores enough data to
|
||||||
|
be able to render the menu bar (username and avatar), along with Cred so it's
|
||||||
|
impossible to have a Viewer if you aren't logged in.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api exposing (Cred)
|
||||||
|
import Avatar exposing (Avatar)
|
||||||
|
import Email exposing (Email)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, required)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Username exposing (Username)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Viewer
|
||||||
|
= Viewer Avatar Cred
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
cred : Viewer -> Cred
|
||||||
|
cred (Viewer _ val) =
|
||||||
|
val
|
||||||
|
|
||||||
|
|
||||||
|
username : Viewer -> Username
|
||||||
|
username (Viewer _ val) =
|
||||||
|
Api.username val
|
||||||
|
|
||||||
|
|
||||||
|
avatar : Viewer -> Avatar
|
||||||
|
avatar (Viewer val _) =
|
||||||
|
val
|
||||||
|
|
||||||
|
|
||||||
|
{-| Passwords must be at least this many characters long!
|
||||||
|
-}
|
||||||
|
minPasswordChars : Int
|
||||||
|
minPasswordChars =
|
||||||
|
6
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder (Cred -> Viewer)
|
||||||
|
decoder =
|
||||||
|
Decode.succeed Viewer
|
||||||
|
|> custom (Decode.field "image" Avatar.decoder)
|
||||||
|
|
||||||
|
|
||||||
|
store : Viewer -> Cmd msg
|
||||||
|
store (Viewer avatarVal credVal) =
|
||||||
|
Api.storeCredWith
|
||||||
|
credVal
|
||||||
|
avatarVal
|
39
src/index.js
39
src/index.js
|
@ -1,39 +0,0 @@
|
||||||
const express = require('express');
|
|
||||||
const cookieParser = require('cookie-parser');
|
|
||||||
const Config = require('./config.js');
|
|
||||||
|
|
||||||
const UserInterface = require('./user.js');
|
|
||||||
|
|
||||||
let app = express();
|
|
||||||
|
|
||||||
app.use(cookieParser());
|
|
||||||
|
|
||||||
// force https
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
if (Config.config.https) {
|
|
||||||
if (req.headers['x-forwarded-proto'] !== 'https') {
|
|
||||||
return res.redirect(`https://${req.headers.host}${req.url}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!Config.config.secret) {
|
|
||||||
console.error("No password secret found. please set `secret` in config.json");
|
|
||||||
process.exit();
|
|
||||||
}
|
|
||||||
else if(Config.config.https && Config.config.secret == "TEST_SECRET") {
|
|
||||||
console.error("please do not use the testing secret in production.");
|
|
||||||
process.exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use("/user", UserInterface.router);
|
|
||||||
|
|
||||||
// serve static files last
|
|
||||||
// app.use(express.static("./static"));
|
|
||||||
// DISABLED: no longer needs to serve static files
|
|
||||||
// due to frontend being employed in elm
|
|
||||||
|
|
||||||
app.listen(Config.config.port || 8080, () => {
|
|
||||||
console.log(`listening on port ${Config.config.port || 8080}`);
|
|
||||||
});
|
|
|
@ -1,8 +0,0 @@
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -3,5 +3,7 @@
|
||||||
"https": false,
|
"https": false,
|
||||||
"alter_db": true,
|
"alter_db": true,
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"db_url": "postgres://postgres:@127.0.0.1/todo"
|
"db_url": "postgres://postgres:@127.0.0.1/todo",
|
||||||
|
"cert": "",
|
||||||
|
"cert_key": ""
|
||||||
}
|
}
|
|
@ -4,6 +4,10 @@
|
||||||
"description": "todo list app (because it hasnt been done before)",
|
"description": "todo list app (because it hasnt been done before)",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"who": "pwd",
|
||||||
|
"build": "cd frontend; elm make src/Main.elm --output=../static/elm.js",
|
||||||
|
"debug": "cd frontend; elm make src/Main.elm --debug --output=../static/elm.js",
|
||||||
|
"prod": "cd frontend; elm make src/Main.elm --optimize --output=../static/elm.js",
|
||||||
"start": "node src/index.js"
|
"start": "node src/index.js"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
@ -14,6 +18,7 @@
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie-parser": "^1.4.5",
|
"cookie-parser": "^1.4.5",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"pg": "^8.6.0",
|
"pg": "^8.6.0",
|
||||||
"sequelize": "^6.6.2"
|
"sequelize": "^6.6.2"
|
|
@ -1,5 +1,6 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie-parser: 1.4.5
|
cookie-parser: 1.4.5
|
||||||
|
cors: 2.8.5
|
||||||
express: 4.17.1
|
express: 4.17.1
|
||||||
pg: 8.6.0
|
pg: 8.6.0
|
||||||
sequelize: 6.6.2_pg@8.6.0
|
sequelize: 6.6.2_pg@8.6.0
|
||||||
|
@ -88,6 +89,15 @@ packages:
|
||||||
node: '>= 0.6'
|
node: '>= 0.6'
|
||||||
resolution:
|
resolution:
|
||||||
integrity: sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
|
integrity: sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
|
||||||
|
/cors/2.8.5:
|
||||||
|
dependencies:
|
||||||
|
object-assign: 4.1.1
|
||||||
|
vary: 1.1.2
|
||||||
|
dev: false
|
||||||
|
engines:
|
||||||
|
node: '>= 0.10'
|
||||||
|
resolution:
|
||||||
|
integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
|
||||||
/debug/2.6.9:
|
/debug/2.6.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.0.0
|
ms: 2.0.0
|
||||||
|
@ -333,6 +343,12 @@ packages:
|
||||||
node: '>= 0.6'
|
node: '>= 0.6'
|
||||||
resolution:
|
resolution:
|
||||||
integrity: sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
|
integrity: sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
|
||||||
|
/object-assign/4.1.1:
|
||||||
|
dev: false
|
||||||
|
engines:
|
||||||
|
node: '>=0.10.0'
|
||||||
|
resolution:
|
||||||
|
integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
|
||||||
/on-finished/2.3.0:
|
/on-finished/2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ee-first: 1.1.1
|
ee-first: 1.1.1
|
||||||
|
@ -679,6 +695,7 @@ packages:
|
||||||
integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
|
integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
|
||||||
specifiers:
|
specifiers:
|
||||||
cookie-parser: ^1.4.5
|
cookie-parser: ^1.4.5
|
||||||
|
cors: ^2.8.5
|
||||||
express: ^4.17.1
|
express: ^4.17.1
|
||||||
pg: ^8.6.0
|
pg: ^8.6.0
|
||||||
sequelize: ^6.6.2
|
sequelize: ^6.6.2
|
62
todo/src/index.js
Normal file
62
todo/src/index.js
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const cors = require('cors');
|
||||||
|
const express = require('express');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const Config = require('./config.js');
|
||||||
|
|
||||||
|
const UserInterface = require('./user.js');
|
||||||
|
|
||||||
|
let credentials = {};
|
||||||
|
|
||||||
|
if (Config.config.https) {
|
||||||
|
if (
|
||||||
|
fs.existsSync(Config.config.cert) &&
|
||||||
|
fs.existsSync(Config.config.cert_key)
|
||||||
|
) {
|
||||||
|
credentials.key = fs.readFileSync(Config.config.cert_key);
|
||||||
|
credentials.cert = fs.readFileSync(Config.config.cert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = express();
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// force https
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (Config.config.https) {
|
||||||
|
if (req.headers['x-forwarded-proto'] !== 'https') {
|
||||||
|
return res.redirect(`https://${req.headers.host}${req.url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Config.config.secret) {
|
||||||
|
console.error('No password secret found. please set `secret` in config.json');
|
||||||
|
process.exit();
|
||||||
|
} else if (Config.config.https && Config.config.secret == 'TEST_SECRET') {
|
||||||
|
console.error('please do not use the testing secret in production.');
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use('/api/user', UserInterface.router);
|
||||||
|
|
||||||
|
// serve static files last
|
||||||
|
app.use(express.static('./static'));
|
||||||
|
// DISABLED: no longer needs to serve static files
|
||||||
|
// due to frontend being employed in elm
|
||||||
|
|
||||||
|
if (Config.config.https) {
|
||||||
|
var server = https.createServer(credentials, app);
|
||||||
|
server.listen(Config.config.port || 8080);
|
||||||
|
} else {
|
||||||
|
var server = http.createServer(app);
|
||||||
|
server.listen(Config.config.port || 8080);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`listening on port ${Config.config.port || 8080}` +
|
||||||
|
` with https ${Config.config.https ? 'enabled' : 'disabled'}`
|
||||||
|
);
|
|
@ -13,6 +13,9 @@ user_cache = {};
|
||||||
email_cache = {};
|
email_cache = {};
|
||||||
|
|
||||||
async function get_user_details(id) {
|
async function get_user_details(id) {
|
||||||
|
if (!id) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
console.log(`search for user with id ${id}`);
|
console.log(`search for user with id ${id}`);
|
||||||
if (!user_cache[id]) {
|
if (!user_cache[id]) {
|
||||||
let user = await Database.schemas.user.findOne({where: {id: id}});
|
let user = await Database.schemas.user.findOne({where: {id: id}});
|
||||||
|
@ -22,7 +25,7 @@ async function get_user_details(id) {
|
||||||
user_cache[user.id] = {
|
user_cache[user.id] = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
password_hash: user.password_hash
|
password_hash: user.password_hash,
|
||||||
};
|
};
|
||||||
email_cache[user.email] = user.id;
|
email_cache[user.email] = user.id;
|
||||||
}
|
}
|
||||||
|
@ -31,6 +34,9 @@ async function get_user_details(id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get_user_details_by_email(email) {
|
async function get_user_details_by_email(email) {
|
||||||
|
if (!email) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
console.log(`search for user with email ${email}}`);
|
console.log(`search for user with email ${email}}`);
|
||||||
if (!email_cache[email] || !user_cache[email_cache[email]]) {
|
if (!email_cache[email] || !user_cache[email_cache[email]]) {
|
||||||
let user = await Database.schemas.user.findOne({where: {email: email}});
|
let user = await Database.schemas.user.findOne({where: {email: email}});
|
||||||
|
@ -40,7 +46,7 @@ async function get_user_details_by_email(email) {
|
||||||
user_cache[user.id] = {
|
user_cache[user.id] = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
password_hash: user.password_hash
|
password_hash: user.password_hash,
|
||||||
};
|
};
|
||||||
email_cache[user.email] = user.id;
|
email_cache[user.email] = user.id;
|
||||||
}
|
}
|
||||||
|
@ -48,10 +54,10 @@ async function get_user_details_by_email(email) {
|
||||||
return user_cache[email_cache[email]];
|
return user_cache[email_cache[email]];
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get("/byEmail/:email", async (req, res) => {
|
router.get('/byEmail/:email', async (req, res) => {
|
||||||
if (!req.params?.email) {
|
if (!req.params?.email) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: "email is a required parameter"
|
error: 'email is a required parameter',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let user = get_user_details_by_email(req.params.email);
|
let user = get_user_details_by_email(req.params.email);
|
||||||
|
@ -59,30 +65,33 @@ router.get("/byEmail/:email", async (req, res) => {
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
res.json({
|
res.json({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email
|
email: user.email,
|
||||||
});
|
});
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
res.sendStatus(404);
|
res.sendStatus(404);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function hash(secret, password) {
|
function hash(secret, password) {
|
||||||
let pw_hash = crypto.pbkdf2Sync(password,
|
let pw_hash = crypto.pbkdf2Sync(
|
||||||
|
password,
|
||||||
secret,
|
secret,
|
||||||
Config.config.key?.iterations || 1000,
|
Config.config.key?.iterations || 1000,
|
||||||
Config.config.key?.length || 64,
|
Config.config.key?.length || 64,
|
||||||
"sha512");
|
'sha512'
|
||||||
|
);
|
||||||
|
|
||||||
return pw_hash.toString('base64');
|
return pw_hash.toString('base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
function verify(secret, password, hash) {
|
function verify(secret, password, hash) {
|
||||||
let pw_hash = crypto.pbkdf2Sync(password,
|
let pw_hash = crypto.pbkdf2Sync(
|
||||||
|
password,
|
||||||
secret,
|
secret,
|
||||||
Config.config.key?.iterations || 1000,
|
Config.config.key?.iterations || 1000,
|
||||||
Config.config.key?.length || 64,
|
Config.config.key?.length || 64,
|
||||||
"sha512");
|
'sha512'
|
||||||
|
);
|
||||||
|
|
||||||
return hash === pw_hash.toString('base64');
|
return hash === pw_hash.toString('base64');
|
||||||
}
|
}
|
||||||
|
@ -103,8 +112,7 @@ function get_session_token(id, token) {
|
||||||
function verify_session_token(id, hash, token) {
|
function verify_session_token(id, hash, token) {
|
||||||
if (session_entropy[id]) {
|
if (session_entropy[id]) {
|
||||||
return verify(session_entropy[id], hash, token);
|
return verify(session_entropy[id], hash, token);
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -112,106 +120,116 @@ function verify_session_token(id, hash, token) {
|
||||||
async function enforce_session_login(req, res, next) {
|
async function enforce_session_login(req, res, next) {
|
||||||
let userid = req.cookies?.userid;
|
let userid = req.cookies?.userid;
|
||||||
let session_token = req.cookies?._session;
|
let session_token = req.cookies?._session;
|
||||||
console.log("a", userid, session_token);
|
console.log('a', userid, session_token);
|
||||||
if (!userid || !session_token) {
|
if (!userid || !session_token) {
|
||||||
res.sendStatus(401);
|
return res.sendStatus(401);
|
||||||
}
|
}
|
||||||
let user = await get_user_details(userid);
|
let user = await get_user_details(userid);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.sendStatus(401);
|
return res.sendStatus(401);
|
||||||
}
|
}
|
||||||
let verified_session = verify_session_token(userid, user.password_hash, session_token);
|
let verified_session = verify_session_token(
|
||||||
|
userid,
|
||||||
|
user.password_hash,
|
||||||
|
session_token
|
||||||
|
);
|
||||||
if (!verified_session) {
|
if (!verified_session) {
|
||||||
res.sendStatus(401);
|
return res.sendStatus(401);
|
||||||
}
|
}
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post("/new", async (req, res) => {
|
router.post('/new', async (req, res) => {
|
||||||
if (!req.body?.email || !req.body?.password) {
|
if (!req.body?.email || !req.body?.password) {
|
||||||
res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "must have email and password fields"
|
error: 'must have email and password fields',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let user = await get_user_details_by_email(req.body.email);
|
let user = await get_user_details_by_email(req.body.email);
|
||||||
console.log(user);
|
console.log(user);
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
res.status(403).json({
|
return res.status(403).json({
|
||||||
error: `email ${req.body.email} is already in use.`
|
error: `email ${req.body.email} is already in use.`,
|
||||||
});
|
});
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
let user = await Database.schemas.user.create({
|
let user = await Database.schemas.user.create({
|
||||||
email: String(req.body.email),
|
email: String(req.body.email),
|
||||||
password_hash: hash_password(req.body.password)
|
password_hash: hash_password(req.body.password),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
return res.json({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email
|
email: user.email,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/login", async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
if (!req.body?.email || !req.body?.password) {
|
if (!req.body?.email || !req.body?.password) {
|
||||||
res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "must have email and password fields"
|
error: 'must have email and password fields',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let user = await get_user_details_by_email(req.body.email);
|
let user = await get_user_details_by_email(req.body.email);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "incorrect email or password"
|
error: 'incorrect email or password',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let verified = verify_password(req.body.password, user.password_hash);
|
let verified = verify_password(req.body.password, user.password_hash);
|
||||||
|
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "incorrect email or password"
|
error: 'incorrect email or password',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.cookie("userid", user.id);
|
res.cookie('userid', user.id, {
|
||||||
res.cookie("_session", get_session_token(user.id, user.password_hash));
|
httpOnly: true,
|
||||||
res.sendStatus(204);
|
secure: true,
|
||||||
|
});
|
||||||
|
res.cookie('_session', get_session_token(user.id, user.password_hash), {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
});
|
||||||
|
return res.sendStatus(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/:id(([a-f0-9\-])+)", async (req, res) => {
|
router.get('/:id([a-f0-9-]+)', async (req, res) => {
|
||||||
|
console.log(req.params);
|
||||||
if (!req.params?.id) {
|
if (!req.params?.id) {
|
||||||
res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "must have id parameter"
|
error: 'must have id parameter',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let user = await get_user_details(req.body.id);
|
let id = req.params?.id;
|
||||||
|
console.log(id);
|
||||||
|
let user = await get_user_details(id);
|
||||||
console.log(user);
|
console.log(user);
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
res.json({
|
return res.json({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email
|
email: user.email,
|
||||||
});
|
});
|
||||||
}
|
} else {
|
||||||
else {
|
return res.sendStatus(404);
|
||||||
res.sendStatus(404);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.use("/authorized", enforce_session_login);
|
router.use('/authorized', enforce_session_login);
|
||||||
router.get("/authorized", async (req, res) => {
|
router.get('/authorized', async (req, res) => {
|
||||||
let userid = req.cookies?.userid;
|
let userid = req.cookies?.userid;
|
||||||
let user = get_user_details(userid);
|
let user = await get_user_details(userid);
|
||||||
res.json({
|
return res.json({
|
||||||
authorized: true,
|
authorized: true,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email
|
email: user.email,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
router: router,
|
router: router,
|
||||||
enforce_session_login: enforce_session_login
|
enforce_session_login: enforce_session_login,
|
||||||
};
|
};
|
Loading…
Reference in a new issue