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 + } +}