From 981b69f025b130a8793fed2d1ddff47768443017 Mon Sep 17 00:00:00 2001 From: Josh Ashton Date: Sun, 10 Aug 2025 13:00:35 -0600 Subject: [PATCH] init commit from when still exploring using Neo4J for DB --- auth.go | 83 +++++++++ go.mod | 10 + go.sum | 8 + posts.go | 527 ++++++++++++++++++++++++++++++++++++++++++++++++++++ router.go | 186 +++++++++++++++++++ serveAPI.go | 44 +++++ spaces.go | 279 ++++++++++++++++++++++++++++ users.go | 229 +++++++++++++++++++++++ 8 files changed, 1366 insertions(+) create mode 100644 auth.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 posts.go create mode 100644 router.go create mode 100644 serveAPI.go create mode 100644 spaces.go create mode 100644 users.go diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..9cd7d29 --- /dev/null +++ b/auth.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "fmt" + // "log" + "net/http" + "os" + // "strconv" + "strings" + + "github.com/golang-jwt/jwt" +) + +// Define a custom context key for storing user information +var userCtxKey = &contextKey{"user"} + +type contextKey struct { + name string +} + +// TODO: Provide user with JWT for login/guest + +// AuthMiddleware is our JWT verification middleware +func auth_middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 1. Extract the JWT from the request (e.g., Authorization header) + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + tokenString := "" + parts := strings.Split(authHeader, " ") + if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { + tokenString = parts[1] + } else { + http.Error(w, "Invalid Authorization header format", http.StatusUnauthorized) + return + } + + // 2. Verify the JWT + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + // Validate the signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("invalid signing method: %v", token.Header["alg"]) + } + // Replace "your-secret-key" with your actual secret key + return []byte(os.Getenv("API_SECRET_KEY")), nil + }) + + if err != nil { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + // 3. Optionally, extract user information from the claims and add it to the request context + user_id_raw, ok := claims["user_id"].(float64) + if !ok { + http.Error(w, "Invalid user ID in token", http.StatusInternalServerError) + return + } + + user_id := int(user_id_raw) + + // Create a new context with the user information + ctx := context.WithValue(r.Context(), userCtxKey, user_id) + // Call the next handler in the chain, passing the new context + next.ServeHTTP(w, r.WithContext(ctx)) + } else { + http.Error(w, "Invalid token claims", http.StatusUnauthorized) + return + } + }) +} + +// Helper function to extract user information from the context in your handlers +func user_id_from_context(ctx context.Context) (int, bool) { + userID, ok := ctx.Value(userCtxKey).(int) + return userID, ok +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c0fe95b --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module the.hub/m + +go 1.24.2 + +require ( + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/neo4j/neo4j-go-driver/v5 v5.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..210ae8c --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/neo4j/neo4j-go-driver/v5 v5.28.0 h1:chDT68PHNa8JZRmjSkGzAbk1weLWo4rMtDvccvpobg0= +github.com/neo4j/neo4j-go-driver/v5 v5.28.0/go.mod h1:Vff8OwT7QpLm7L2yYr85XNWe9Rbqlbeb9asNXJTHO4k= diff --git a/posts.go b/posts.go new file mode 100644 index 0000000..c45c655 --- /dev/null +++ b/posts.go @@ -0,0 +1,527 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + + // "encoding/json" + // "fmt" + // "io" + "log" + "net/http" + + // "os" + // "os/signal" + "strconv" + //"strings" + // "sync" + // "time" + "github.com/gorilla/mux" + "github.com/neo4j/neo4j-go-driver/v5/neo4j" +) + +func create_new_post(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + err := r.ParseMultipartForm(10 << 20) // Limit to 10MB + if err != nil { + log.Println("Unable to parse form:", err) + http.Error(w, "Unable to parse form", http.StatusBadRequest) + return + } + + space_id, err := strconv.Atoi(r.FormValue("space_id")) + if err != nil { + log.Println("Invalid space id:", err) + http.Error(w, "Invalid space id.", http.StatusBadRequest) + return + } + + // Required fields + user_id, ok := user_id_from_context(r.Context()) + if !ok { + log.Println("Invalid user ID") + http.Error(w, "Invalid user ID", http.StatusBadRequest) + return + } + + if !auth_user_in_space(driver, driver_ctx, user_id, space_id) { + log.Println("User", user_id, "attempted access to space", space_id, ", which is not allowed.") + http.Error(w, "You are not a member of that space.", http.StatusUnauthorized) + return + } + + post_type := r.FormValue("post_type") + post_status := r.FormValue("post_status") + content := r.FormValue("content") + //create_space := r.FormValue("create_space") + + if post_type == "" || content == "" || post_status == "" { + http.Error(w, "Missing required fields", http.StatusBadRequest) + return + } + + // Optional fields + title := r.FormValue("title") + excerpt := r.FormValue("excerpt") + location := r.FormValue("location") + raw_topics := r.FormValue("topics") + // files := r.FormFile("files") // Consider using r.FormFile for actual uploads + // images := r.Form("images") // Consider using r.Form for multiple URLs + // videos := r.Form("videos") // Consider using r.Form for multiple URLs + + // TODO: Implement space retrieval or creation logic based on space_name and create_space + + topics := []string{} + if raw_topics != "" { + topicList := strings.SplitSeq(raw_topics, ",") + for topic := range topicList { + trimmedTopic := strings.TrimSpace(topic) + if trimmedTopic != "" { + topics = append(topics, trimmedTopic) + } + } + } + + topics_rel_stmt := "" + if len(topics) > 0 { + topics_rel_stmt = ` + WITH p + FOREACH (topicName IN $topicsList | + MERGE (t:Topic {name: topicName}) + CREATE (p)-[:TAGGED_WITH]->(t) + ) + ` + } + + stmt := ` + MATCH (u:User) WHERE id(u) = $user_id + MATCH (t:PostType) WHERE t.name = $post_type + MATCH (s:Space) WHERE id(s) = $space_id + MATCH (st:PostStatus) WHERE st.name = $post_status + + CREATE + (u)-[:CREATED {time: datetime()}]->(p:Post { + content: $content, + title: $title, + excerpt: $excerpt, + location: $location + }) + + CREATE (p)-[:POST_TYPE]->(t) + CREATE (p)-[:IN_SPACE]->(s) + CREATE (p)-[:STATUS]->(st) + + ` + topics_rel_stmt + + stmt_args := map[string]any{ + "user_id": user_id, + "post_type": post_type, + "space_id": space_id, + "post_status": post_status, + "content": content, + "title": title, + "excerpt": excerpt, + "location": location, + "topicsList": topics, + } + + _, err = neo4j.ExecuteQuery(driver_ctx, driver, stmt, stmt_args, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) + + if err != nil { + log.Println("Error creating post:", err) + http.Error(w, "Failed to create post", http.StatusInternalServerError) + return + } else { + w.WriteHeader(http.StatusCreated) + w.Write([]byte("Post created successfully!")) + return + } + } +} + +// TODO: Need to fix bug where only one topic/topic name are being returned in JSON response. + +func retrieve_all_posts(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + // TODO: Handle anonymous users. + user_id, ok := user_id_from_context(r.Context()) + if !ok { + http.Error(w, "Invalid user ID.", http.StatusBadRequest) + return + } + + stmt := ` + MATCH (p:Post)-[:IN_SPACE]->(s:Space) + MATCH (u:User)-[:SPACE_MEMBER]->(s) + MATCH (s)-[:SPACE_TYPE]->(st:SpaceType) + MATCH (p)-[:POST_TYPE]->(pt:PostType) + MATCH (p)-[:STATUS]->(ps:PostStatus) + MATCH (p)-[:TAGGED_WITH]->(t:Topic) + MATCH (a:User)-[:CREATED]->(p) + + WHERE + id(u) = $user_id AND + ps.name = "Published" + + RETURN + id(a) AS author_id, + a.username AS author_username, + a.profile_picture AS author_picture, + + p.title AS title, + p.excerpt AS excerpt, + p.location AS location, + p.content AS content, + + pt.name AS post_type, + id(t) AS topic_id, + t.name AS topic_name, + + id(s) AS space_id, + s.name AS space_name, + st.name AS space_type + ` + + stmt_args := map[string]any{ + "user_id": user_id, + } + + result, err := neo4j.ExecuteQuery(driver_ctx, driver, stmt, stmt_args, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) + + if err != nil { + log.Println("Error retrieving all posts:", err) + http.Error(w, "Failed to retrieve all posts", http.StatusInternalServerError) + return + } + + posts_obj := map[int]any{} + + for index, record := range result.Records { + space_id, _ := record.Get("space_id") + space_id = int(space_id.(int64)) + if !auth_user_in_space(driver, driver_ctx, user_id, space_id.(int)) { + http.Error(w, "You are not allowed in this space.", http.StatusUnauthorized) + return + } + + author_id, _ := record.Get("author_id") + author_username, _ := record.Get("author_username") + author_picture, _ := record.Get("author_picture") + + title, _ := record.Get("title") + excerpt, _ := record.Get("excerpt") + location, _ := record.Get("location") + content, _ := record.Get("content") + + post_type, _ := record.Get("post_type") + topic_id, _ := record.Get("topic_id") + topic_name, _ := record.Get("topic_name") + space_name, _ := record.Get("space_name") + + record_obj := map[string]any{ + "author_id": author_id, + "author_username": author_username, + "author_picture": author_picture, + "title": title, + "excerpt": excerpt, + "location": location, + "content": content, + "post_type": post_type, + "topic_id": topic_id, + "topic_name": topic_name, + "space_id": space_id, + "space_name": space_name, + } + + posts_obj[index] = record_obj + } + + obj, err := json.Marshal(posts_obj) + if err != nil { + http.Error(w, "Failed to parse into JSON", http.StatusInternalServerError) + log.Println("error marshalling json") + return + } + + w.WriteHeader(http.StatusOK) + w.Write(obj) + return + } +} + +// TODO: Need to fix bug where only one topic/topic name are being returned in JSON response. +func retrieve_post(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + // TODO: Handle anonymous users. + user_id, ok := user_id_from_context(r.Context()) + if !ok { + http.Error(w, "Invalid user ID.", http.StatusBadRequest) + return + } + + post_id, err := strconv.Atoi(mux.Vars(r)["post_id"]) + if err != nil { + http.Error(w, "Invalid Post ID.", http.StatusBadRequest) + return + } + + stmt := ` + MATCH (p:Post)-[:IN_SPACE]->(s:Space) + MATCH (s)-[:SPACE_TYPE]->(st:SpaceType) + MATCH (p)-[:POST_TYPE]->(pt:PostType) + MATCH (p)-[:STATUS]->(ps:PostStatus) + MATCH (p)-[:TAGGED_WITH]->(t:Topic) + MATCH (a:User)-[:CREATED]->(p) + + WHERE + id(p) = $post_id + + RETURN + id(a) AS author_id, + a.username AS author_username, + a.profile_picture AS author_picture, + + p.title AS title, + p.excerpt AS excerpt, + p.location AS location, + p.content AS content, + + pt.name AS post_type, + ps.name AS post_status, + id(t) AS topic_id, + t.name AS topic_name, + + id(s) AS space_id, + st.name AS space_type + ` + + stmt_args := map[string]any{ + "post_id": post_id, + } + + result, err := neo4j.ExecuteQuery(driver_ctx, driver, stmt, stmt_args, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) + if err != nil { + http.Error(w, "Failed to retrieve Post.", http.StatusInternalServerError) + return + } + + if len(result.Records) != 2 { + http.Error(w, "Post does not exist.", http.StatusBadRequest) + return + } + + record := result.Records[0] + space_id, _ := record.Get("space_id") + space_id = int(space_id.(int64)) + if !auth_user_in_space(driver, driver_ctx, user_id, space_id.(int)) { + http.Error(w, "You are not allowed in this space.", http.StatusUnauthorized) + return + } + + author_id, _ := record.Get("author_id") + author_username, _ := record.Get("author_username") + author_picture, _ := record.Get("author_picture") + title, _ := record.Get("title") + excerpt, _ := record.Get("excerpt") + location, _ := record.Get("location") + content, _ := record.Get("content") + post_type, _ := record.Get("post_type") + post_status, _ := record.Get("post_status") + topic_id, _ := record.Get("topic_id") + topic_name, _ := record.Get("topic_name") + space_type, _ := record.Get("space_type") + + record_obj := map[string]any{ + "author_id": author_id, + "author_username": author_username, + "author_picture": author_picture, + "title": title, + "excerpt": excerpt, + "location": location, + "content": content, + "post_type": post_type, + "post_status": post_status, + "topic_id": topic_id, + "topic_name": topic_name, + "space_id": space_id, + "space_type": space_type, + } + + obj, err := json.Marshal(record_obj) + if err != nil { + http.Error(w, "Failed to parse into JSON", http.StatusInternalServerError) + log.Println("error marshalling json") + return + } + + w.WriteHeader(http.StatusOK) + w.Write(obj) + return + } +} + +// TODO: Need to test. +func update_post(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + err := r.ParseMultipartForm(10 << 20) // Limit to 10MB + if err != nil { + log.Println("Unable to parse form:", err) + http.Error(w, "Unable to parse form", http.StatusBadRequest) + return + } + + space_id, err := strconv.Atoi(r.FormValue("space_id")) + if err != nil { + log.Println("Invalid space id:", err) + http.Error(w, "Invalid space id.", http.StatusBadRequest) + return + } + + user_id, ok := user_id_from_context(r.Context()) + if !ok { + http.Error(w, "Invalid user ID.", http.StatusUnauthorized) + return + } + + if !auth_user_in_space(driver, driver_ctx, user_id, space_id) { + log.Println("User", user_id, "attempted access to space", space_id, ", which is not allowed.") + http.Error(w, "You are not a member of that space.", http.StatusUnauthorized) + return + } + + post_id, err := strconv.Atoi(mux.Vars(r)["post_id"]) + if err != nil { + http.Error(w, "Failed to get Post ID.", http.StatusInternalServerError) + return + } + + post_type := r.FormValue("post_type") + post_status := r.FormValue("post_status") + content := r.FormValue("content") + //create_space := r.FormValue("create_space") + + if post_type == "" || content == "" || post_status == "" { + http.Error(w, "Missing required fields.", http.StatusBadRequest) + return + } + + // Optional fields + title := r.FormValue("title") + excerpt := r.FormValue("excerpt") + location := r.FormValue("location") + raw_topics := r.FormValue("topics") + // files := r.FormFile("files") // Consider using r.FormFile for actual uploads + // images := r.Form("images") // Consider using r.Form for multiple URLs + // videos := r.Form("videos") // Consider using r.Form for multiple URLs + + // TODO: Implement space retrieval or creation logic based on space_name and create_space + + topics := []string{} + if raw_topics != "" { + topicList := strings.SplitSeq(raw_topics, ",") + for topic := range topicList { + trimmedTopic := strings.TrimSpace(topic) + if trimmedTopic != "" { + topics = append(topics, trimmedTopic) + } + } + } + + topics_rel_stmt := "" + if len(topics) > 0 { + topics_rel_stmt = ` + WITH p + FOREACH (topicName IN $topicsList | + MERGE (t:Topic {name: topicName}) + MERGE (p)-[:TAGGED_WITH]->(t) + ) + ` + } + + stmt := ` + MATCH (u:User) WHERE id(u) = $user_id + MATCH (t:PostType) WHERE t.name = $post_type + MATCH (s:Space) WHERE id(s) = $space_id + MATCH (st:PostStatus) WHERE st.name = $post_status + + SET + p.content = $content, + p.title = $title, + p.excerpt = $excerpt, + p.location = $location, + p.updated_at = datetime() + + + MERGE (p)-[:POST_TYPE]->(t) + MERGE (p)-[:IN_SPACE]->(s) + MERGE (p)-[:STATUS]->(st) + + ` + topics_rel_stmt + + stmt_args := map[string]any{ + "user_id": user_id, + "post_type": post_type, + "space_id": space_id, + "post_status": post_status, + "content": content, + "title": title, + "excerpt": excerpt, + "location": location, + "topicsList": topics, + } + + _, err = neo4j.ExecuteQuery(driver_ctx, driver, stmt, stmt_args, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) + if err != nil { + log.Println("Attempted to update Post", post_id, "which failed.") + http.Error(w, "Unable to update Post.", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + return + } +} + +func delete_post(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + user_id, ok := user_id_from_context(r.Context()) + if !ok { + http.Error(w, "Invalid user ID.", http.StatusUnauthorized) + return + } + + post_id, err := strconv.Atoi(mux.Vars(r)["post_id"]) + if err != nil { + http.Error(w, "Invalid Post ID.", http.StatusBadRequest) + return + } + + stmt := "MATCH (u:User)-[:CREATED]->(p:Post) WHERE id(u) = $user_id AND id(p) = $post_id DETACH DELETE p" + + stmt_args := map[string]any{ + "user_id": user_id, + "post_id": post_id, + } + + _, err = neo4j.ExecuteQuery(driver_ctx, driver, stmt, stmt_args, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) + + if err != nil { + http.Error(w, "Unable to delete Post.", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + return + } +} diff --git a/router.go b/router.go new file mode 100644 index 0000000..490178e --- /dev/null +++ b/router.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + // "encoding/json" + "fmt" + //"io" + "log" + "net/http" + "os" + "os/signal" + //"strings" + "sync" + "time" + + "github.com/gorilla/mux" + "github.com/joho/godotenv" + "github.com/neo4j/neo4j-go-driver/v5/neo4j" +) + +func send_error(w http.ResponseWriter, json []byte) { + w.WriteHeader(http.StatusBadRequest) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, string(json)) +} + +func send_response(w http.ResponseWriter, json []byte) { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, string(json)) +} + +func serve() { + // TODO: Authenticate user credentials to verify they are allowed to even access + var wg sync.WaitGroup + + driver_ctx := context.Background() + driver, err := neo4j.NewDriverWithContext(os.Getenv("NEO4J_BOLT"), neo4j.BasicAuth("neo4j", os.Getenv("NEO4J_PASSWORD"), "")) + if err != nil { + panic(err) + } + + defer driver.Close(driver_ctx) + + err = driver.VerifyConnectivity(driver_ctx) + if err != nil { + log.Println("Error connecting to neo4j.") + panic(err) + } + + fmt.Println("neo4j connection established.") + + rsrc_endpoints := []string{ + "/auth", + "/users", + "/users", + "/users/{user_id}", + "/users/{user_id}", + "/users/{user_id}", + "/users/{user_id}/posts", + "/users/{user_id}/spaces", + + "/spaces", + "/spaces", + "/spaces/{space_id}", + //"/spaces/{space_id}", + "/spaces/{space_id}", + "/spaces/{space_id}", + "/spaces/{space_id}/users", + "/spaces/{space_id}/posts", + + "/posts", + "/posts", + "/posts/{post_id}", + "/posts/{post_id}", + "/posts/{post_id}", + } + + methods := []string{ + http.MethodPost, + http.MethodPost, + http.MethodGet, + http.MethodGet, + http.MethodPatch, + http.MethodDelete, + http.MethodGet, + http.MethodGet, + + http.MethodPost, + http.MethodGet, + http.MethodGet, + //http.MethodPost, + http.MethodPatch, + http.MethodDelete, + http.MethodGet, + http.MethodGet, + + http.MethodPost, + http.MethodGet, + http.MethodGet, + http.MethodPatch, + http.MethodDelete, + } + + functions := []func(http.ResponseWriter, *http.Request){ + auth_user(driver, driver_ctx), + create_new_user(driver, driver_ctx), + retrieve_all_users(driver, driver_ctx), + retrieve_user(driver, driver_ctx), + update_user(driver, driver_ctx), + delete_user(driver, driver_ctx), + retrieve_users_posts(driver, driver_ctx), + retrieve_users_spaces(driver, driver_ctx), + + create_new_space(driver, driver_ctx), + retrieve_all_spaces(driver, driver_ctx), + retrieve_space(driver, driver_ctx), + //auth_user_in_space(driver, driver_ctx), + update_space(driver, driver_ctx), + delete_space(driver, driver_ctx), + retrieve_spaces_users(driver, driver_ctx), + retrieve_spaces_posts(driver, driver_ctx), + + create_new_post(driver, driver_ctx), + retrieve_all_posts(driver, driver_ctx), + retrieve_post(driver, driver_ctx), + update_post(driver, driver_ctx), + delete_post(driver, driver_ctx), + } + + r := mux.NewRouter() + + ServeApi(r, "api", rsrc_endpoints, methods, functions, "7477") + log.Println("API routes configured with prefix", "/api") + + addr := "0.0.0.0:7476" + + srv := &http.Server{ + Addr: addr, + WriteTimeout: time.Second * 15, + ReadTimeout: time.Second * 15, + IdleTimeout: time.Second * 60, + Handler: r, + } + + // Run our server in a goroutine so that it doesn't block. + wg.Add(1) + go func() { + defer wg.Done() + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Println(err) + } + }() + + log.Println("Router is running on port 7476") + + c := make(chan os.Signal, 1) + // We'll accept graceful shutdowns when quit via SIGINT (Ctrl+Shift+C) + signal.Notify(c, os.Interrupt) + + // Block until we receive our signal. + <-c + + // Create a deadline to wait for. + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(30)) + defer cancel() + // Doesn't block if no connections, but will otherwise wait + // until the timeout deadline. + srv.Shutdown(ctx) + // Optionally, you could run srv.Shutdown in a goroutine and block on + // <-ctx.Done() if your application should wait for other services + // to finalize based on context cancellation. + log.Println("shutting down") + wg.Wait() + os.Exit(0) +} + +func main() { + err := godotenv.Load("../.env") + if err != nil { + log.Println("Error loading .env.") + return + } + + serve() +} diff --git a/serveAPI.go b/serveAPI.go new file mode 100644 index 0000000..83a535f --- /dev/null +++ b/serveAPI.go @@ -0,0 +1,44 @@ +package main + +import ( + "github.com/gorilla/mux" + "log" + "net/http" + "time" +) + +// Serve an API using mux.Router().Host({domain}).Subrouter(). +// Provide the router, domain, an array of endpoints and functions, and +// the port you would like the API accessible to. +func ServeApi( + router *mux.Router, + prefix string, + endpoints []string, + methods []string, + functions []func(http.ResponseWriter, *http.Request), + port string, +) { + + apiR := router.PathPrefix(prefix).Subrouter() + + for i := range endpoints { + apiR.HandleFunc(endpoints[i], functions[i]).Methods(methods[i]) + } + apiR.Use(auth_middleware) + + apiSrv := &http.Server{ + Addr: "0.0.0.0:" + port, + WriteTimeout: time.Second * 15, + ReadTimeout: time.Second * 15, + IdleTimeout: time.Second * 60, + Handler: apiR, + } + + go func() { + if err := apiSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Println(err) + } + }() + + log.Println("API server is running on port " + port) +} diff --git a/spaces.go b/spaces.go new file mode 100644 index 0000000..74bb070 --- /dev/null +++ b/spaces.go @@ -0,0 +1,279 @@ +package main + +import ( + "context" + "encoding/json" + // "fmt" + //"io" + "log" + "net/http" + // "os" + // "os/signal" + "strconv" + //"strings" + // "sync" + // "time" + "github.com/gorilla/mux" + "github.com/neo4j/neo4j-go-driver/v5/neo4j" +) + +// TODO: Need to test. +func create_new_space(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Must be a user to create a space. + user_id, ok := user_id_from_context(r.Context()) + if !ok { + log.Println("Invalid user ID") + http.Error(w, "Invalid user ID", http.StatusBadRequest) + return + } + + err := r.ParseMultipartForm(10 << 20) // Limit to 10MB + if err != nil { + log.Println("Unable to parse form:", err) + http.Error(w, "Unable to parse form", http.StatusBadRequest) + return + } + + var space map[string]any + err = json.NewDecoder(r.Body).Decode(&space) + + if space["id"] != -1 { + log.Println("Attempt to create a space with a defined ID") + http.Error(w, "Space ID cannot be provided.", http.StatusBadRequest) + return + } + + stmt := ` + MATCH (owner:User) + WHERE id(owner) = $user_id + + CREATE (s:Space { + created_at: datetime(), + name: $name, + description: $description + }) + + CREATE (owner)-[:SPACE_MEMBER {role: "Owner"}]->(s) + + UNWIND $members as member + MATCH (m:User) + WHERE id(m) = member.id + CREATE (m)-[:SPACE_MEMBER {role: member.role}]->(s) + + RETURN id(s) AS id, + s.name AS name + ` + + stmt_args := map[string]any{ + "user_id": user_id, + "name": space["name"], + "description": space["description"], + "members": space["members"], + } + + result, err := neo4j.ExecuteQuery(driver_ctx, driver, stmt, stmt_args, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) + + if err != nil || len(result.Records) != 2 { + log.Println("Error creating Space:", err) + http.Error(w, "Failed to create Space", http.StatusInternalServerError) + return + } + + record := result.Records[0] + space_id, _ := record.Get("id") + space_name, _ := record.Get("name") + + record_obj := map[string]any{ + "space_id": space_id, + "space_name": space_name, + } + + obj, err := json.Marshal(record_obj) + if err != nil { + http.Error(w, "Failed to marshal JSON.", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + w.Write(obj) + return + } +} + +// TODO: Need to implement for ADMIN ONLY +func retrieve_all_spaces(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + return + } +} + +func retrieve_space(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + return + } +} + +// TODO: Implement access for Public and Open Spaces +func auth_user_in_space(driver neo4j.DriverWithContext, driver_ctx context.Context, user_id int, space_id int) bool { + stmt := ` + MATCH (u:User), (s:Space) + WHERE id(u) = $user_id AND id(s) = $space_id + RETURN EXISTS((u)-[:SPACE_MEMBER]->(s)) + ` + + stmt_args := map[string]any{ + "user_id": user_id, + "space_id": space_id, + } + + result, err := neo4j.ExecuteQuery(driver_ctx, driver, stmt, stmt_args, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) + if err != nil { + log.Println("Something went wrong authorizing the user to a space.") + return false + } + + if len(result.Records) > 0 { + return result.Records[0].Values[0].(bool) + } else { + return false + } +} + +// TODO: Implement +func update_space(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + return + } +} + +// TODO: Implement +func delete_space(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + return + } +} + +// TODO: Need to handle multiple topics. +func retrieve_spaces_posts(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Must be a user to create a space. + user_id, ok := user_id_from_context(r.Context()) + if !ok { + log.Println("Invalid user ID") + http.Error(w, "Invalid user ID", http.StatusBadRequest) + return + } + + space_id, _ := strconv.Atoi(mux.Vars(r)["space_id"]) + + if !auth_user_in_space(driver, driver_ctx, user_id, space_id) { + log.Println("User", user_id, "attempted access to space", space_id, ", which is not allowed.") + http.Error(w, "You are not a member of that space.", http.StatusUnauthorized) + return + } + + stmt := ` + MATCH (p:Post)-[:IN_SPACE]->(s:Space) + MATCH (s)-[:SPACE_TYPE]->(st:SpaceType) + MATCH (p)-[:POST_TYPE]->(pt:PostType) + MATCH (p)-[:STATUS]->(ps:PostStatus) + MATCH (p)-[:TAGGED_WITH]->(t:Topic) + MATCH (a:User)-[:CREATED]->(p) + + WHERE + id(s) = $space_id AND + ps.name = "Published" + + RETURN + id(a) AS author_id, + a.username AS author_username, + a.profile_picture AS author_picture, + + p.title AS title, + p.excerpt AS excerpt, + p.location AS location, + p.content AS content, + + pt.name AS post_type, + id(t) AS topic_id, + t.name AS topic_name, + + s.name AS space_name + ` + + stmt_args := map[string]any{ + "space_id": space_id, + } + + result, err := neo4j.ExecuteQuery(driver_ctx, driver, stmt, stmt_args, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j")) + + if err != nil { + log.Println("Something went wrong retrieving Posts for a Space. Error:", err) + http.Error(w, "Error retrieving Posts from Space.", http.StatusInternalServerError) + return + } + // + // if !(len(result.Records) >= 2) { + // log.Println("Failed to retrieve Posts for Space", space_id, "\n Error:", err) + // http.Error(w, "Error retrieving Posts from Space.", http.StatusInternalServerError) + // return + // } + + posts_obj := map[int]any{} + for index, record := range result.Records { + + author_id, _ := record.Get("author_id") + author_username, _ := record.Get("author_username") + author_picture, _ := record.Get("author_picture") + + title, _ := record.Get("title") + excerpt, _ := record.Get("excerpt") + location, _ := record.Get("location") + content, _ := record.Get("content") + + post_type, _ := record.Get("post_type") + topic_id, _ := record.Get("topic_id") + topic_name, _ := record.Get("topic_name") + space_name, _ := record.Get("space_name") + + record_obj := map[string]any{ + "author_id": author_id, + "author_username": author_username, + "author_picture": author_picture, + "title": title, + "excerpt": excerpt, + "location": location, + "content": content, + "post_type": post_type, + "topic_id": topic_id, + "topic_name": topic_name, + "space_name": space_name, + } + + posts_obj[index] = record_obj + } + + obj, err := json.Marshal(posts_obj) + if err != nil { + http.Error(w, "Failed to parse into JSON", http.StatusInternalServerError) + log.Println("error marshalling json") + return + } + + w.WriteHeader(http.StatusOK) + w.Write(obj) + return + } +} + +// TODO: Implement for Space Member role == Administrator OR role == Owner +func retrieve_spaces_users(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + return + } +} diff --git a/users.go b/users.go new file mode 100644 index 0000000..b8240b2 --- /dev/null +++ b/users.go @@ -0,0 +1,229 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strconv" + + //"io" + "log" + "net/http" + + // "os" + // "os/signal" + // "reflect" + "math/rand" + "strings" + + // "sync" + // "time" + "github.com/gorilla/mux" + "github.com/neo4j/neo4j-go-driver/v5/neo4j" +) + +func id_exists(id int, driver neo4j.DriverWithContext, driver_ctx context.Context) bool { + result, _ := neo4j.ExecuteQuery(driver_ctx, driver, + "MATCH (u:User) WHERE id(u) = $id RETURN count(u)", + map[string]any{ + "id": id, + }, neo4j.EagerResultTransformer, + neo4j.ExecuteQueryWithDatabase("neo4j")) + + if len(result.Records) > 0 { + return true + } else { + return false + } +} + +func create_new_user(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + err := r.ParseMultipartForm(10 << 20) // Limit to 10MB + if err != nil { + log.Println("Unable to parse form.") + } + + username := r.FormValue("username") + email := r.FormValue("email") + password_hash := r.FormValue("password_hash") + + id := rand.Intn(999999999999) + 1 + for id_exists(id, driver, driver_ctx) { + id = rand.Intn(999999999999) + 1 + } + + profile_picture_file, header, err := r.FormFile("profile_picture") + if err != nil { + log.Println("Error retrieving file from form.") + } + + defer profile_picture_file.Close() + filename_parts := strings.Split(header.Filename, ".") + ext := filename_parts[len(filename_parts)-1] + + dst, err := os.Create(os.Getenv("PROFILE_IMAGES_FQ_PATH") + strconv.Itoa(id) + ext) + if err != nil { + log.Println("Error saving profile picture.") + } + defer dst.Close() + + _, err = io.Copy(dst, profile_picture_file) + if err != nil { + log.Println("Error writing file to filesystem.") + } + + result, _ := neo4j.ExecuteQuery(driver_ctx, driver, + "CREATE (u:User {id: $id, username: $username, email: $email, password_hash: $password_hash, profile_picture: $profile_picture, created_at: datetime()})-[:USER_TYPE]->(:Role {name: 'User'}) RETURN elementId(u) AS id", + map[string]any{ + "id": id, + "username": username, + "email": email, + "password_hash": password_hash, + "profile_picture": strconv.Itoa(id) + ext, + }, neo4j.EagerResultTransformer, + neo4j.ExecuteQueryWithDatabase("neo4j")) + + if len(result.Records) > 0 { + ret := struct { + Auth bool `json:"auth"` + Id string `json:"id"` + }{ + Auth: true, + Id: strconv.Itoa(id), + } + + json, err := json.Marshal(ret) + if err != nil { + return + } + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, string(json)) + log.Println("User ID:", id, "Action: Signup") + } else { + return + } + } +} + +func retrieve_all_users(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + return + } +} + +func retrieve_user(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.Atoi(mux.Vars(r)["user_id"]) + + result, _ := neo4j.ExecuteQuery(driver_ctx, driver, + "MATCH (u:User) WHERE id(u) = $user_id RETURN u.username AS username, u.email AS email, u.profile_picture AS profile_picture", + map[string]any{ + "user_id": id, + }, neo4j.EagerResultTransformer, + neo4j.ExecuteQueryWithDatabase("neo4j")) + + if len(result.Records) > 0 { + record := result.Records[0] + vals := record.AsMap() + user := struct { + Username string `json:"username"` + Email string `json:"email"` + ProfilePicture string `json:"profile_picture"` + }{ + Username: vals["username"].(string), + Email: vals["email"].(string), + ProfilePicture: vals["profile_picture"].(string), + } + + json, err := json.Marshal(user) + if err != nil { + return + } + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, string(json)) + log.Println("User ID:", id, "Action: Retrieval") + } else { + return + } + } +} + +func auth_user(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + username := r.FormValue("username") + password_hash := r.FormValue("password_hash") + + result, _ := neo4j.ExecuteQuery(driver_ctx, driver, + "MATCH (u:User) WHERE u.username = $username RETURN u.password_hash AS password_hash, elementId(u) AS id", + map[string]any{ + "username": username, + }, neo4j.EagerResultTransformer, + neo4j.ExecuteQueryWithDatabase("neo4j")) + + if len(result.Records) > 0 { + record := result.Records[0] + vals := record.AsMap() + if vals["password_hash"] != password_hash { + return + } + + id := strings.Split(vals["id"].(string), ":")[2] + + ret := struct { + Auth bool `json:"auth"` + Id string `json:"id"` + }{ + Auth: true, + Id: id, + } + + json, err := json.Marshal(ret) + if err != nil { + return + } + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, string(json)) + log.Println("User ID:", id, "Action: Login") + } else { + return + } + + } +} + +func update_user(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + return + } +} + +func delete_user(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + return + } +} + +func retrieve_users_posts(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + return + } +} + +func retrieve_users_spaces(driver neo4j.DriverWithContext, driver_ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + return + } +}