package httpserver import ( "context" "fmt" "io" "mime" "net/http" "path/filepath" "time" "github.com/golang-jwt/jwt" jwtRequest "github.com/golang-jwt/jwt/request" "github.com/gorilla/mux" "github.com/graphql-go/graphql" "github.com/graphql-go/graphql/gqlerrors" "github.com/graphql-go/handler" "github.com/minio/minio-go/v7" log "github.com/sirupsen/logrus" helper "git.kapelle.org/niklas/s3browser/internal/helper" "git.kapelle.org/niklas/s3browser/internal/loader" types "git.kapelle.org/niklas/s3browser/internal/types" ) var ( tokenExp = int64((time.Hour * 23).Seconds()) ) type cookieExtractor struct { Name string } func (c *cookieExtractor) ExtractToken(req *http.Request) (string, error) { cookie, err := req.Cookie(c.Name) if err == nil && len(cookie.Value) != 0 { return cookie.Value, nil } return "", jwtRequest.ErrNoTokenInRequest } // InitHttp setup and start the http server. Blocking func InitHttp(resolveContext context.Context, schema graphql.Schema, address string) error { r := mux.NewRouter() gqlHandler := handler.New(&handler.Config{ Schema: &schema, Pretty: true, GraphiQL: false, Playground: true, FormatErrorFn: func(err error) gqlerrors.FormattedError { switch err := err.(type) { case gqlerrors.FormattedError: log.Error("GQL: ", err.Message) case *gqlerrors.Error: log.Errorf("GQL: '%s' at '%v'", err.Message, err.Path) } return gqlerrors.FormatError(err) }, }) r.Use(func(h http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { parsedToken, err := jwtRequest.ParseFromRequestWithClaims(r, jwtRequest.MultiExtractor{ jwtRequest.AuthorizationHeaderExtractor, &cookieExtractor{Name: "jwt"}, }, &types.JWTClaims{}, jwtKeyFunc) if err == nil && parsedToken.Valid { newRequest := r.WithContext(context.WithValue(r.Context(), "jwt", parsedToken)) h.ServeHTTP(rw, newRequest) return } h.ServeHTTP(rw, r) }) }) r.HandleFunc("/api/graphql", func(rw http.ResponseWriter, r *http.Request) { token := r.Context().Value("jwt") refreshTokenIfNeeded(rw, r) gqlHandler.ContextHandler(context.WithValue(resolveContext, "jwt", token), rw, r) }) r.HandleFunc("/api/file", func(rw http.ResponseWriter, r *http.Request) { refreshTokenIfNeeded(rw, r) httpGetFile(resolveContext, rw, r) }).Methods("GET") r.HandleFunc("/api/file", func(rw http.ResponseWriter, r *http.Request) { refreshTokenIfNeeded(rw, r) httpPostFile(resolveContext, rw, r) }).Methods("POST") r.HandleFunc("/api/cookie", setLoginCookie).Methods("POST") r.HandleFunc("/api/logout", logout).Methods("POST") // Init the embedded static files initStatic(r) return http.ListenAndServe(address, r) } func httpGetFile(ctx context.Context, rw http.ResponseWriter, r *http.Request) { if !helper.IsAuthenticated(r.Context()) { rw.WriteHeader(http.StatusUnauthorized) return } s3Client := ctx.Value("s3Client").(*minio.Client) idString := r.URL.Query().Get("id") id := types.ParseID(idString) if id == nil { // Failed to parse ID rw.WriteHeader(http.StatusBadRequest) return } log.Debug("S3 'StatObject': ", id) objInfo, err := s3Client.StatObject(context.Background(), id.Bucket, id.Key, minio.GetObjectOptions{}) if err != nil { log.Error("Failed to get object info: ", err) rw.WriteHeader(http.StatusInternalServerError) return } reqEtag := r.Header.Get("If-None-Match") if reqEtag == objInfo.ETag { rw.WriteHeader(http.StatusNotModified) return } log.Debug("S3 'GetObject': ", id) obj, err := s3Client.GetObject(context.Background(), id.Bucket, id.Key, minio.GetObjectOptions{}) if err != nil { log.Error("Failed to get object: ", err) rw.WriteHeader(http.StatusInternalServerError) return } rw.Header().Set("Cache-Control", "must-revalidate") rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base((objInfo.Key)))) rw.Header().Set("Content-Type", objInfo.ContentType) rw.Header().Set("ETag", objInfo.ETag) _, err = io.Copy(rw, obj) if err != nil { rw.WriteHeader(http.StatusInternalServerError) return } } func httpPostFile(ctx context.Context, rw http.ResponseWriter, r *http.Request) { if !helper.IsAuthenticated(r.Context()) { rw.WriteHeader(http.StatusUnauthorized) return } s3Client := ctx.Value("s3Client").(*minio.Client) idString := r.URL.Query().Get("id") id := types.ParseID(idString) if id == nil { // Failed to parse ID rw.WriteHeader(http.StatusBadRequest) return } id.Normalize() contentType := r.Header.Get("Content-Type") mimeType, _, _ := mime.ParseMediaType(contentType) log.Debug("S3 'PutObject': ", id) _, err := s3Client.PutObject(context.Background(), id.Bucket, id.Key, r.Body, r.ContentLength, minio.PutObjectOptions{ ContentType: mimeType, }) if err != nil { rw.WriteHeader(http.StatusInternalServerError) return } loader := ctx.Value("loader").(*loader.Loader) loader.InvalidedCacheForId(ctx, *id) rw.WriteHeader(http.StatusCreated) } func jwtKeyFunc(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"]) } return []byte("TODO"), nil } //setLoginCookie if provieded a valid JWT in the body then set a httpOnly cookie with the token func setLoginCookie(rw http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) defer r.Body.Close() if err != nil { rw.WriteHeader(http.StatusInternalServerError) return } tokenString := string(body) token, err := jwt.ParseWithClaims(tokenString, &types.JWTClaims{}, jwtKeyFunc) if err != nil { rw.WriteHeader(http.StatusInternalServerError) return } if !token.Valid { rw.WriteHeader(http.StatusUnauthorized) return } claims, ok := token.Claims.(*types.JWTClaims) if !ok { rw.WriteHeader(http.StatusInternalServerError) return } cookie := &http.Cookie{ Name: "jwt", Value: tokenString, HttpOnly: true, SameSite: http.SameSiteStrictMode, Path: "/api", Expires: time.Unix(claims.ExpiresAt, 0), } http.SetCookie(rw, cookie) rw.WriteHeader(http.StatusNoContent) } //logout removes the jwt cookie func logout(rw http.ResponseWriter, r *http.Request) { cookie := &http.Cookie{ Name: "jwt", Value: "", Path: "/api", Expires: time.Unix(0, 0), HttpOnly: true, SameSite: http.SameSiteStrictMode, } http.SetCookie(rw, cookie) rw.WriteHeader(http.StatusNoContent) } func refreshTokenIfNeeded(rw http.ResponseWriter, r *http.Request) { currentToken, ok := r.Context().Value("jwt").(*jwt.Token) if !ok && currentToken == nil { return } claims, ok := currentToken.Claims.(*types.JWTClaims) if !ok { log.Error("Failed to refresh JWT") return } // Refresh only if token older than 1 hour if (claims.ExpiresAt - time.Now().Unix()) > tokenExp { return } newToken := helper.CreateJWT(claims) tokenString, err := newToken.SignedString([]byte("TODO")) if err != nil { log.Error("Failed to refresh JWT") return } cookie := &http.Cookie{ Name: "jwt", Value: tokenString, HttpOnly: true, SameSite: http.SameSiteStrictMode, Path: "/api", Expires: time.Unix(int64(claims.ExpiresAt), 0), } http.SetCookie(rw, cookie) log.Debug("Refreshed JWT") }