frontend: introduce static frontend
This commit is contained in:
parent
27a8595cbc
commit
a364713031
10
Dockerfile
10
Dockerfile
|
@ -5,9 +5,17 @@ COPY go.sum .
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN GOBIN=/mi/bin go install ./cmd/...
|
RUN GOBIN=/mi/bin go install ./cmd/...
|
||||||
|
RUN apk --no-cache add curl \
|
||||||
|
&& curl -L -o elm.gz https://github.com/elm/compiler/releases/download/0.19.1/binary-for-linux-64-bit.gz \
|
||||||
|
&& gunzip elm.gz \
|
||||||
|
&& chmod +x ./elm \
|
||||||
|
&& mv elm /usr/local/bin \
|
||||||
|
&& cd ./frontend \
|
||||||
|
&& elm make --output ../static/main.js src/Main.elm
|
||||||
|
|
||||||
FROM xena/alpine
|
FROM xena/alpine
|
||||||
COPY --from=build /mi/bin /usr/local/bin
|
COPY --from=build /mi/bin /usr/local/bin
|
||||||
COPY ./run /run
|
COPY ./run /run
|
||||||
RUN mkdir -p /mi
|
COPY --from=build /mi/static /mi/static
|
||||||
|
WORKDIR /mi
|
||||||
CMD ["/bin/sh", "/run/start.sh"]
|
CMD ["/bin/sh", "/run/start.sh"]
|
||||||
|
|
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -74,6 +75,11 @@ func main() {
|
||||||
ln.FatalErr(ctx, err)
|
ln.FatalErr(ctx, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mux.Handle("/static/", http.FileServer(http.Dir(".")))
|
||||||
|
mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
http.ServeFile(rw, req, "./static/index.html")
|
||||||
|
})
|
||||||
|
|
||||||
tok, err := pm.CreateToken(
|
tok, err := pm.CreateToken(
|
||||||
[][2]string{
|
[][2]string{
|
||||||
{"from", "main"},
|
{"from", "main"},
|
||||||
|
@ -96,6 +102,11 @@ func main() {
|
||||||
mi.RegisterRoutes()
|
mi.RegisterRoutes()
|
||||||
sc := switchcounter.New(session, mux)
|
sc := switchcounter.New(session, mux)
|
||||||
|
|
||||||
|
mux.HandleFunc("/.within/tokeninfo", func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
tokenData := req.Context().Value(tokenKey)
|
||||||
|
json.NewEncoder(rw).Encode(tokenData)
|
||||||
|
})
|
||||||
|
|
||||||
if *switchFile != "" {
|
if *switchFile != "" {
|
||||||
fin, err := os.Open(*switchFile)
|
fin, err := os.Open(*switchFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
"flag"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -11,7 +11,6 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/o1egl/paseto/v2"
|
"github.com/o1egl/paseto/v2"
|
||||||
"golang.org/x/crypto/ed25519"
|
"golang.org/x/crypto/ed25519"
|
||||||
"within.website/ln"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -66,31 +65,44 @@ func (pm PasetoMiddleware) CreateToken(data [][2]string, expiration time.Time) (
|
||||||
return tok, nil
|
return tok, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var publicRoutes map[string]bool
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
publicRoutes = map[string]bool{
|
||||||
|
"/webhooks/": true,
|
||||||
|
"/static/": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
tokenKey ctxKey = iota
|
||||||
|
)
|
||||||
|
|
||||||
func (pm PasetoMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (pm PasetoMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
tok := r.Header.Get("Authorization")
|
tok := r.Header.Get("Authorization")
|
||||||
var newJsonToken paseto.JSONToken
|
var newJsonToken paseto.JSONToken
|
||||||
var newFooter string
|
var newFooter string
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if r.URL.EscapedPath() == "/.within/botinfo" {
|
if r.URL.EscapedPath() == "/.within/botinfo" || r.URL.EscapedPath() == "/" {
|
||||||
goto ok
|
goto ok
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(r.URL.EscapedPath(), "/webhooks/") {
|
for k := range publicRoutes {
|
||||||
goto ok
|
if strings.HasPrefix(r.URL.EscapedPath(), k) {
|
||||||
|
goto ok
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pm.v2.Verify(tok, pm.pubKey, &newJsonToken, &newFooter)
|
err = pm.v2.Verify(tok, pm.pubKey, &newJsonToken, &newFooter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ln.Error(r.Context(), err)
|
|
||||||
http.Error(w, "Not allowed", http.StatusForbidden)
|
http.Error(w, "Not allowed", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.URL.EscapedPath() == "/.within/tokeninfo" {
|
r = r.WithContext(context.WithValue(r.Context(), tokenKey, newJsonToken))
|
||||||
json.NewEncoder(w).Encode(newJsonToken)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ok:
|
ok:
|
||||||
pm.next.ServeHTTP(w, r)
|
pm.next.ServeHTTP(w, r)
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
elm-stuff
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"type": "application",
|
||||||
|
"source-directories": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"elm-version": "0.19.1",
|
||||||
|
"dependencies": {
|
||||||
|
"direct": {
|
||||||
|
"elm/browser": "1.0.2",
|
||||||
|
"elm/core": "1.0.4",
|
||||||
|
"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.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test-dependencies": {
|
||||||
|
"direct": {},
|
||||||
|
"indirect": {}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,215 @@
|
||||||
|
module Main exposing (..)
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (..)
|
||||||
|
import Html.Events exposing (onInput)
|
||||||
|
import Http
|
||||||
|
import Json.Decode as D
|
||||||
|
import Url
|
||||||
|
|
||||||
|
-- MAIN
|
||||||
|
main : Program () Model Msg
|
||||||
|
main =
|
||||||
|
Browser.application
|
||||||
|
{ init = init
|
||||||
|
, view = view
|
||||||
|
, update = update
|
||||||
|
, subscriptions = subscriptions
|
||||||
|
, onUrlChange = UrlChanged
|
||||||
|
, onUrlRequest = LinkClicked
|
||||||
|
}
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
type alias Model =
|
||||||
|
{ key : Nav.Key
|
||||||
|
, url : Url.Url
|
||||||
|
, token: Maybe String
|
||||||
|
, token_data: Maybe TokenData
|
||||||
|
, switch_data : Maybe ( List Switch )
|
||||||
|
}
|
||||||
|
|
||||||
|
init : () -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||||
|
init flags url key =
|
||||||
|
( Model key url Nothing Nothing Nothing, Cmd.none )
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
type Msg
|
||||||
|
= LinkClicked Browser.UrlRequest
|
||||||
|
| UrlChanged Url.Url
|
||||||
|
| TokenInput String
|
||||||
|
| TokenValidate ( Result Http.Error TokenData )
|
||||||
|
| Logout
|
||||||
|
| NoSwitchData
|
||||||
|
| GotSwitchData ( List Switch )
|
||||||
|
|
||||||
|
type alias Switch
|
||||||
|
= { id : String
|
||||||
|
, who : String
|
||||||
|
, started_at : String
|
||||||
|
, ended_at : Maybe String
|
||||||
|
, duration : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
type alias TokenData
|
||||||
|
= { sub : String
|
||||||
|
, jti : String
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenDecoder : D.Decoder TokenData
|
||||||
|
tokenDecoder =
|
||||||
|
D.map2 TokenData
|
||||||
|
(D.field "sub" D.string)
|
||||||
|
(D.field "jti" D.string)
|
||||||
|
|
||||||
|
request method token path body expect =
|
||||||
|
Http.request
|
||||||
|
{ method = method
|
||||||
|
, body = body
|
||||||
|
, headers =
|
||||||
|
[ Http.header "Authorization" token
|
||||||
|
]
|
||||||
|
, url = path
|
||||||
|
, expect = expect
|
||||||
|
, timeout = Nothing
|
||||||
|
, tracker = Nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
LinkClicked urlRequest ->
|
||||||
|
case urlRequest of
|
||||||
|
Browser.Internal url ->
|
||||||
|
( model, Nav.pushUrl model.key (Url.toString url) )
|
||||||
|
|
||||||
|
Browser.External href ->
|
||||||
|
( model, Nav.load href )
|
||||||
|
|
||||||
|
UrlChanged url ->
|
||||||
|
case url.path of
|
||||||
|
"/logout" ->
|
||||||
|
( model, Nav.load "/" )
|
||||||
|
|
||||||
|
default ->
|
||||||
|
( { model | url = url }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
TokenInput token ->
|
||||||
|
( { model | token = Just token }
|
||||||
|
, request "GET" token "/.within/tokeninfo" Http.emptyBody (expectJson TokenValidate tokenDecoder)
|
||||||
|
)
|
||||||
|
|
||||||
|
TokenValidate result ->
|
||||||
|
case result of
|
||||||
|
Ok data ->
|
||||||
|
( { model | token_data = Just data }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
Err _ ->
|
||||||
|
( { model | token = Nothing }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
Logout ->
|
||||||
|
( { model | token = Nothing, token_data = Nothing }
|
||||||
|
, Nav.load "/"
|
||||||
|
)
|
||||||
|
_ ->
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions _ =
|
||||||
|
Sub.none
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
view : Model -> Browser.Document Msg
|
||||||
|
view model =
|
||||||
|
case model.token of
|
||||||
|
Nothing ->
|
||||||
|
template "Login"
|
||||||
|
[ h1 [] [ text "Login" ]
|
||||||
|
, viewInput "password" "API Token" "" TokenInput
|
||||||
|
]
|
||||||
|
Just token ->
|
||||||
|
case model.url.path of
|
||||||
|
"/" ->
|
||||||
|
template "Mi"
|
||||||
|
[ navBar
|
||||||
|
, h1 [] [ text "Mi" ]
|
||||||
|
, p [] [ text "TODO: everything" ]
|
||||||
|
, p []
|
||||||
|
[ text "Token sub: "
|
||||||
|
, text (Maybe.withDefault (TokenData "" "") model.token_data).sub
|
||||||
|
, text " ID: "
|
||||||
|
, text (Maybe.withDefault (TokenData "" "") model.token_data).jti
|
||||||
|
]
|
||||||
|
]
|
||||||
|
other ->
|
||||||
|
template "Not found"
|
||||||
|
[ navBar
|
||||||
|
, h1 [] [ text "Not found" ]
|
||||||
|
, p []
|
||||||
|
[ text "The requested URL "
|
||||||
|
, b [] [ text other ]
|
||||||
|
, text " was not found."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
viewInput : String -> String -> String -> (String -> msg) -> Html msg
|
||||||
|
viewInput t p v toMsg =
|
||||||
|
input [ type_ t, placeholder p, value v, onInput toMsg ] []
|
||||||
|
|
||||||
|
viewLink : String -> String -> Html msg
|
||||||
|
viewLink path title =
|
||||||
|
a [ href path ] [ text title ]
|
||||||
|
|
||||||
|
template : String -> List (Html msg) -> Browser.Document msg
|
||||||
|
template title body =
|
||||||
|
{ title = title
|
||||||
|
, body =
|
||||||
|
[ node "main" []
|
||||||
|
body
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
navBar : Html msg
|
||||||
|
navBar =
|
||||||
|
node "nav" []
|
||||||
|
[ p []
|
||||||
|
[ viewLink "/" "Mi"
|
||||||
|
, text " - "
|
||||||
|
, viewLink "/switch" "Switch tracker"
|
||||||
|
, text " - "
|
||||||
|
, viewLink "/logout" "Logout"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
expectJson : (Result Http.Error a -> msg) -> D.Decoder a -> Http.Expect msg
|
||||||
|
expectJson toMsg decoder =
|
||||||
|
Http.expectStringResponse toMsg <|
|
||||||
|
\response ->
|
||||||
|
case response of
|
||||||
|
Http.BadUrl_ url ->
|
||||||
|
Err (Http.BadUrl url)
|
||||||
|
|
||||||
|
Http.Timeout_ ->
|
||||||
|
Err Http.Timeout
|
||||||
|
|
||||||
|
Http.NetworkError_ ->
|
||||||
|
Err Http.NetworkError
|
||||||
|
|
||||||
|
Http.BadStatus_ metadata body ->
|
||||||
|
Err (Http.BadStatus metadata.statusCode)
|
||||||
|
|
||||||
|
Http.GoodStatus_ metadata body ->
|
||||||
|
case D.decodeString decoder body of
|
||||||
|
Ok value ->
|
||||||
|
Ok value
|
||||||
|
|
||||||
|
Err err ->
|
||||||
|
Err (Http.BadBody (D.errorToString err))
|
|
@ -0,0 +1 @@
|
||||||
|
main.js
|
|
@ -0,0 +1,74 @@
|
||||||
|
main {
|
||||||
|
font-family: monospace, monospace;
|
||||||
|
max-width: 38rem;
|
||||||
|
padding: 2rem;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-device-width: 736px) {
|
||||||
|
main {
|
||||||
|
padding: 0rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: #d3869b;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #282828;
|
||||||
|
color: #ebdbb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: #3c3836;
|
||||||
|
padding: 1em;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a, a:active, a:visited {
|
||||||
|
color: #b16286;
|
||||||
|
background-color: #1d2021;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5 {
|
||||||
|
margin-bottom: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 1px solid #bdae93;
|
||||||
|
margin: 0.5em 10px;
|
||||||
|
padding: 0.5em 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
body {
|
||||||
|
background: #fbf1c7;
|
||||||
|
color: #3c3836;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: #ebdbb2;
|
||||||
|
padding: 1em;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a, a:active, a:visited {
|
||||||
|
color: #b16286;
|
||||||
|
background-color: #f9f5d7;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5 {
|
||||||
|
margin-bottom: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 1px solid #655c54;
|
||||||
|
margin: 0.5em 10px;
|
||||||
|
padding: 0.5em 10px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Mi</title>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/static/gruvbox.css">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<script src="/static/main.js"></script>
|
||||||
|
</head>
|
||||||
|
<body id="top">
|
||||||
|
<main>
|
||||||
|
<script>var app = Elm.Main.init();</script>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p><a href="https://christine.website">From Within</a> - <a href="#top">Go to the top</a></p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in New Issue