diff --git a/.env.example b/.env.example index b3ed755..a60c023 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ +## +# MANDATORY +## + # Valeurs de configuration de l'importer DOWNLOAD_URL=https://files.opendatarchives.fr/professionnels.ign.fr/parcellaire-express/PCI-par-DEPT_2021-04/ MAX_PARALLEL_DL=4 @@ -18,6 +22,14 @@ API_PORT=8010 # A positive number. '0' means disabled. MAX_FEATURE=1000 +## +# OPTIONAL +## + # The url path to the viewer -# Empty value or do not define to disable +# Leave empty or undefined to disable VIEWER_URL=/viewer + +# A unique secure id +# Leave empty or undefined to disable. +API_KEY= diff --git a/README.md b/README.md index 7b8ed07..3d72d00 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Il est possible de décommenter le service `adminer` dans `docker-compose.yml` p * Configuration de l'API * `API_PORT` : Port d'écoute de l'API. Fixé à `8010` par défaut. * `MAX_FEATURE` : Nombre maximal d'objets retournés par l'API. Fixé à `1000` par défaut. `0` pour désactiver la limite. + * `API_KEY` : (Optionel) Bearer Authentication. Laisser vide ou non défini pour désactiver. * Configuration du viewer * `VIEWER_URL` : (Optionel) Url d'accès à une page de consultation des parcelles. Laisser vide ou non défini pour désactiver. 2. Des options de configuration de PostgreSQL sont définies dans le fichier `docker-compose.yml`. Utiliser [PGTune](https://pgtune.leopard.in.ua/#/) pour les adapter aux caractéristiques de la machine hôte. diff --git a/api/app.go b/api/app.go index d1c81d6..86a4861 100644 --- a/api/app.go +++ b/api/app.go @@ -45,15 +45,24 @@ func (a *App) Run(addr string) { } func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) { - response, err := json.Marshal(payload) - if err != nil { - log.Println("JSON marshalling error") - } w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) - if _, err := w.Write(response); err != nil { - log.Println("Could not send response") + + _err := json.NewEncoder(w).Encode(payload) + if _err != nil { + log.Printf("🚧 JSON encoding error : %v\n", _err) + + w.WriteHeader(http.StatusInternalServerError) + + _err = json.NewEncoder(w).Encode(GeneralMessage{ + Message: "jsonEncodingError", + Error: true, + Literal: "Sorry, cannot output to json", + }) + + log.Panicf("🚨 Cannot output error message : %v\n", _err) } + } func respondWithError(w http.ResponseWriter, code int, message string) { @@ -119,31 +128,45 @@ func (a *App) initializeRoutes() { theViewerUrl, isViewerUrldefined := os.LookupEnv("VIEWER_URL") if isViewerUrldefined { - log.Printf("Html viewer is enabled : %v", isViewerUrldefined) + log.Printf("⭐️ Html viewer is enabled : %v", isViewerUrldefined) + // silent viewer route a.Router.PathPrefix(theViewerUrl).Handler(http.StripPrefix(theViewerUrl, http.FileServer(http.Dir("./views")))).Methods("GET") } - a.Router.Handle("/parcelle/{idu:"+iduRegex+"}", Use(LogMw).ThenFunc(a.getById)).Methods("GET") + // silent route + a.Router.HandleFunc("/status", a.healthCheckHandler).Methods("GET") - a.Router.Handle("/parcelle", Use(LogMw).ThenFunc(a.findByPosition)).Queries( + _mayBeSecured := a.Router.NewRoute().Subrouter() + + _mayBeSecured.Use(LogMw) + + if os.Getenv(ENV_API_KEY) != "" { + log.Printf("⭐️ Api key security is enabled") + _mayBeSecured.Use(AuthMw) + } + + _mayBeSecured.HandleFunc("/parcelle/{idu:"+iduRegex+"}", a.getById).Methods("GET") + + _mayBeSecured.HandleFunc("/parcelle", a.findByPosition).Queries( "pos", "{pos:"+posRegex+"}").Methods("GET") - a.Router.Handle("/parcelle", Use(LogMw).ThenFunc(a.findByPositionSplit)).Queries( + _mayBeSecured.HandleFunc("/parcelle", a.findByPositionSplit).Queries( "lon", "{lon:"+lonRegex+"}", "lat", "{lat:"+latRegex+"}").Methods("GET") - a.Router.Handle("/parcelle", Use(LogMw).ThenFunc(a.findByBbox)).Queries( + _mayBeSecured.HandleFunc("/parcelle", a.findByBbox).Queries( "bbox", "{bbox:"+bboxRegex+"}").Methods("GET") - a.Router.Handle("/parcelle", Use(LogMw).ThenFunc(a.findByBboxSplit)).Queries( + _mayBeSecured.HandleFunc("/parcelle", a.findByBboxSplit).Queries( "lon_min", "{lon_min:"+lonRegex+"}", "lat_min", "{lat_min:"+latRegex+"}", "lon_max", "{lon_max:"+lonRegex+"}", "lat_max", "{lat_max:"+latRegex+"}").Methods("GET") + // handle no argument a.Router.Handle("/parcelle", Use(LogMw).ThenFunc(a.error(http.StatusBadRequest, "Requête invalide"))) - a.Router.Handle("/status", Use(LogMw).ThenFunc(a.healthCheckHandler)).Methods("GET") - + // handle root path a.Router.PathPrefix("/").Handler(Use(LogMw).ThenFunc(a.error(http.StatusNotFound, "URL inconnue"))) + } diff --git a/api/constants.go b/api/constants.go index 27c03aa..90320dc 100644 --- a/api/constants.go +++ b/api/constants.go @@ -20,3 +20,4 @@ const ( /// const ENV_VIEWER_URL = "VIEWER_URL" +const ENV_API_KEY = "API_KEY" diff --git a/api/main.go b/api/main.go index a934dc5..d026792 100644 --- a/api/main.go +++ b/api/main.go @@ -54,7 +54,7 @@ func checkEnv() { fmt.Printf("* %s : %s \n", theEnvName, theEnv) } - optionalEnvs := []string{ENV_VIEWER_URL} + optionalEnvs := []string{ENV_VIEWER_URL, ENV_API_KEY} for _, theEnvName := range optionalEnvs { theEnv, isPresent := os.LookupEnv(theEnvName) diff --git a/api/utils.go b/api/utils.go index 6cb62b1..8c157b8 100644 --- a/api/utils.go +++ b/api/utils.go @@ -1,9 +1,11 @@ package main import ( + "encoding/json" "fmt" "log" "net/http" + "os" "strings" ) @@ -38,6 +40,17 @@ func formatLog(message string, ps ...interface{}) string { // Http Tmux Goodies 😜 // -------------------- +type GeneralMessage struct { + Message string `json:"message"` + Error bool `json:"error"` + Literal string `json:"literal"` +} + +type ContentMessage struct { + GeneralMessage + Payload interface{} `json:"data"` +} + // statusWriter wraps an http response. // As it is not possible to retrieve natively the status and the length // this interface ensure @@ -100,11 +113,11 @@ func Use(mws ...Middleware) Middleware { } } -// LogMw : Add logging to each controller +// LogMw : Add logging to controller // Should be the FISRT middleware. func LogMw(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // ensure response contains status. + // ensures response contains status. _w, okType := w.(interface{}).(statusWriter) if !okType { _w = statusWriter{ResponseWriter: w} @@ -120,4 +133,54 @@ func LogMw(next http.Handler) http.Handler { }) } +// AuthMw : Add Bearer/Token security to controller +func AuthMw(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // ensures response contains status. + _w, okType := w.(interface{}).(statusWriter) + if !okType { + _w = statusWriter{ResponseWriter: w} + } + + _auth := r.Header.Get("Authorization") + + if _auth == "" { + _w.WriteHeader(http.StatusUnauthorized) + _err := json.NewEncoder(&_w).Encode(GeneralMessage{ + Message: "requireAuthorization", + Error: true, + Literal: "Please provides correct Authorization header", + }) + + if _err != nil { + log.Panicf("🚨 Sorry, cannot output unauthorized error message : %v\n", _err) + } + + return + } + + // Supports Bearer or Token api key. + _auth = strings.Replace(_auth, "Bearer ", "", 1) + _auth = strings.Replace(_auth, "Token ", "", 1) + _auth = strings.TrimSpace(_auth) + + if _auth != os.Getenv(ENV_API_KEY) { + _w.WriteHeader(http.StatusForbidden) + _err := json.NewEncoder(&_w).Encode(GeneralMessage{ + Message: "unknownToken", + Error: true, + Literal: "The token is incorrect", + }) + + if _err != nil { + log.Panicf("🚨 Sorry, cannot output auth error message : %v\n", _err) + } + + return + } + + next.ServeHTTP(w, r) + }) +} + // -------------------- diff --git a/api/views/index.html b/api/views/index.html index 0e59ce2..1380249 100644 --- a/api/views/index.html +++ b/api/views/index.html @@ -28,6 +28,8 @@ // try to retrieve server information const baseUrl = `${window.location.protocol}//${window.location.host}`; var reqControl = undefined; + // optional apiKey + var apiKey = undefined; // PARIS const map = L.map("map", { @@ -68,9 +70,44 @@ reqControl = new AbortController(); const { signal } = reqControl; - fetch(`${baseUrl}/parcelle?bbox=${map.getBounds().toBBoxString()}`, { + const _url = `${baseUrl}/parcelle?bbox=${map + .getBounds() + .toBBoxString()}`; + + var _headers = new Headers(); + if (apiKey) { + _headers.append("Authorization", `Bearer ${apiKey}`); + } + + var _promise = fetch(_url, { signal, - }) + headers: _headers, + }); + + _promise.then((response) => { + if (response.status === 401) { + apiKey = prompt("Please provides the apiKey", ""); + + if (apiKey) { + _headers.delete("Authorization"); + _headers.append("Authorization", `Bearer ${apiKey}`); + } + + _promise = fetch(_url, { + signal, + headers: _headers, + }); + } else if (response.status === 403) { + console.warn( + "❌ Forbidden. Apikey is incorrect. Refresh to provide a new one." + ); + apiKey = undefined; + } else { + return response; + } + }); + + _promise .then((e) => e.json()) .then((data) => { console.log("✅ - parcels recieved"); diff --git a/docker-compose.yml b/docker-compose.yml index aa40f38..3134c43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -106,6 +106,7 @@ services: - POSTGRES_PORT - POSTGRES_SCHEMA - API_PORT + - API_KEY - MAX_FEATURE - LIMIT_FEATURE - VIEWER_URL