diff --git a/Dockerfile b/Dockerfile index 620cb07..8421d11 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,9 +5,17 @@ COPY go.sum . RUN go mod download COPY . . 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 COPY --from=build /mi/bin /usr/local/bin COPY ./run /run -RUN mkdir -p /mi +COPY --from=build /mi/static /mi/static +WORKDIR /mi CMD ["/bin/sh", "/run/start.sh"] diff --git a/cmd/mi/main.go b/cmd/mi/main.go index 4fe6191..562081e 100644 --- a/cmd/mi/main.go +++ b/cmd/mi/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "net/http" @@ -74,6 +75,11 @@ func main() { 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( [][2]string{ {"from", "main"}, @@ -96,6 +102,11 @@ func main() { mi.RegisterRoutes() 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 != "" { fin, err := os.Open(*switchFile) if err != nil { diff --git a/cmd/mi/middleware.go b/cmd/mi/middleware.go index 0cf0a9a..62b69d7 100644 --- a/cmd/mi/middleware.go +++ b/cmd/mi/middleware.go @@ -1,8 +1,8 @@ package main import ( + "context" "encoding/hex" - "encoding/json" "flag" "net/http" "strings" @@ -11,7 +11,6 @@ import ( "github.com/google/uuid" "github.com/o1egl/paseto/v2" "golang.org/x/crypto/ed25519" - "within.website/ln" ) var ( @@ -66,31 +65,44 @@ func (pm PasetoMiddleware) CreateToken(data [][2]string, expiration time.Time) ( 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) { tok := r.Header.Get("Authorization") var newJsonToken paseto.JSONToken var newFooter string var err error - if r.URL.EscapedPath() == "/.within/botinfo" { + if r.URL.EscapedPath() == "/.within/botinfo" || r.URL.EscapedPath() == "/" { goto ok } - if strings.HasPrefix(r.URL.EscapedPath(), "/webhooks/") { - goto ok + for k := range publicRoutes { + if strings.HasPrefix(r.URL.EscapedPath(), k) { + goto ok + } } err = pm.v2.Verify(tok, pm.pubKey, &newJsonToken, &newFooter) if err != nil { - ln.Error(r.Context(), err) http.Error(w, "Not allowed", http.StatusForbidden) return } - if r.URL.EscapedPath() == "/.within/tokeninfo" { - json.NewEncoder(w).Encode(newJsonToken) - return - } + r = r.WithContext(context.WithValue(r.Context(), tokenKey, newJsonToken)) ok: pm.next.ServeHTTP(w, r) diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..4bc8535 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1 @@ +elm-stuff diff --git a/frontend/elm.json b/frontend/elm.json new file mode 100644 index 0000000..7e5229d --- /dev/null +++ b/frontend/elm.json @@ -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": {} + } +} diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm new file mode 100644 index 0000000..864ed21 --- /dev/null +++ b/frontend/src/Main.elm @@ -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)) diff --git a/static/.gitignore b/static/.gitignore new file mode 100644 index 0000000..a9b203a --- /dev/null +++ b/static/.gitignore @@ -0,0 +1 @@ +main.js diff --git a/static/gruvbox.css b/static/gruvbox.css new file mode 100644 index 0000000..618eb3b --- /dev/null +++ b/static/gruvbox.css @@ -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; + } +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..c361d8f --- /dev/null +++ b/static/index.html @@ -0,0 +1,18 @@ + + + + Mi + + + + + +
+ + + +
+ +