orm to frontend connected. for a time, i'll be trying multiple solutions at once.

This commit is contained in:
2025-05-16 16:41:07 -06:00
parent 228cf1137b
commit a93c30c40f
38 changed files with 1442 additions and 2407 deletions
-87
View File
@@ -1,87 +0,0 @@
<?php
require_once 'functions/init.php';
require_once 'functions/functions.php';
include_once 'components/head.php';
include_once 'functions/article_functions.php';
include_once 'functions/account_functions.php';
// UI Components
foreach (glob('components/article/*.php') as $file)
require_once $file;
if (isset($_POST['form_id'])) {
switch ($_POST['form_id']) {
case 'compose':
if (isset($_POST['article_id'])) {
$tags = [];
for ($i = 0; $i < count($_POST['tags']); $i++)
$tags[] = $_POST['tags'][$i];
update_article($_POST['article_id'], $_POST['title'], $_POST['excerpt'], $tags, $_POST['markdown']);
header('Location: articles.php?article_id=' . $_POST['article_id']);
} else {
$id = create_article($_SESSION['user_id'], $_POST['title'], $_POST['excerpt'], (isset($_POST['tags']) ? $_POST['tags'] : []), $_POST['markdown']);
$emails = exec_stmt('SELECT email FROM user INNER JOIN follows ON user.user_id = follows.follower_user_id WHERE follows.following_user_id = ? AND follows.notify_user = 1', 'i', $_SESSION['user_id'])->fetch_assoc();
notify_users($emails, $_SESSION['username'], $id, $_POST['title'], $_POST['excerpt']);
header('Location: articles.php?article_id=' . $id);
}
break;
case 'follow':
follow_user($_SESSION['user_id'], $_POST['author_id'], ($_POST['follow'] == 'email_notify' ? 1 : 0));
break;
case 'unfollow':
unfollow_user($_SESSION['user_id'], $_POST['author_id']);
break;
}
}
if (isset($_GET['view']))
$view = $_GET['view'];
else
$view = 'list';
switch ($view) {
case 'list':
echo article_list();
break;
case 'filter':
if (isset($_GET['tag']))
echo article_list(article_ids_by_tag($_GET['tag']));
else if (isset($_GET['author_id']))
echo article_list(articles_ids_by_author($_GET['author_id']));
break;
case 'sort':
if (isset($_GET['sort']) && $_GET['sort'] == 'oldest')
echo article_list(article_ids_by_oldest());
else if (isset($_GET['sort']) && $_GET['sort'] == 'newest')
echo article_list(article_ids_by_newest());
break;
case 'read':
if (isset($_SESSION['user_id']) && isset($_GET['article_id'])) {
increment_read_counter($_GET['article_id']);
echo article_page_from_markdown($_GET['article_id']);
} else {
$redirect = (isset($_GET['article_id']) ? 'articles.php?view=read&article_id=' . $_GET['article_id'] : '');
if ($redirect)
header('Location: user.php?view=login&redirect=' . urlencode($redirect));
else
header('Location: articles.php');
}
break;
case 'compose':
echo compose_view((isset($_GET['article_id']) ? $_GET['article_id'] : ''));
break;
}
if (($view == 'list' || $view == 'sort') && (isset($_SESSION['role_id']) && $_SESSION['role_id'] <= contributor)) {
echo '
<div id="floating_compose">
<a href="articles.php?view=compose"><i class="nf nf-md-note_plus"></i></a>
</div>
';
}
include_once 'components/foot.php';
-81
View File
@@ -1,81 +0,0 @@
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
}
// 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("JWT_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
}
-10
View File
@@ -1,10 +0,0 @@
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
)
-8
View File
@@ -1,8 +0,0 @@
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=
-527
View File
@@ -1,527 +0,0 @@
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", "http://localhost:2025")
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", "http://localhost:2025")
// 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", "http://localhost:2025")
// 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", "http://localhost:2025")
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", "http://localhost:2025")
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
}
}
-186
View File
@@ -1,186 +0,0 @@
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()
}
-44
View File
@@ -1,44 +0,0 @@
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)
}
-273
View File
@@ -1,273 +0,0 @@
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", "http://localhost:2025")
// 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", "http://localhost:2025")
// 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 !(len(result.Records) >= 2) {
log.Println("Failed to retrieve Posts for Space", space_id, ". 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
}
}
-229
View File
@@ -1,229 +0,0 @@
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", "http://localhost:2025")
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", "http://localhost:2025")
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
}
}
-49
View File
@@ -1,49 +0,0 @@
<?php
/*
* Generates cards for a given article.
*/
function article_card($article_id)
{
$article = exec_stmt('SELECT * FROM article WHERE article_id = ?', 'i', $article_id)->fetch_assoc();
$tags_results = exec_stmt('SELECT tag FROM article_tags INNER JOIN tags ON article_tags.tag_id = tags.tag_id WHERE article_id = ?', 'i', $article_id);
$tags_html = '';
if ($tags_results != null) {
$tags_html .= '<div class="tag_row">';
while ($row = $tags_results->fetch_assoc())
$tags_html .= '<a href="/articles.php?view=filter&tag=' . $row['tag'] . '" class="tag">#' . $row['tag'] . '</a>';
$tags_html .= '</div>';
}
$author = exec_stmt('SELECT user_id, username, profile_picture FROM user WHERE user_id = ?', 'i', $article['author_id'])->fetch_assoc();
$card = '
<div class="article_card">
<div class="row_space_between">
<h4>' . $article['title'] . '</h4>
<div class="card_info_box">
<div class="col-right">
<a href="user.php?view=display&user_id=' . $author['user_id'] . '">Written by: ' . $author['username'] . '</a>
<small>Published: ' . $article['published_at'] . '</small>
<small>Updated: ' . $article['updated_at'] . '</small>
</div>
<div class="card_pic_box">
<img src="' . $_ENV['PROFILE_IMAGES_PQ_PATH'] . $author['profile_picture'] . '" class="card_profile_picture">
</div>
</div>
</div>
<div class="line"></div>
<p>' . $article['excerpt'] . '</p>
<div class="row_space_between">
<a href="/articles.php?view=read&article_id=' . $article['article_id'] . '" class="button">Read More</a>
<div class="tag_box">' . $tags_html . '</div>
</div>
</div>
';
return $card;
}
+84
View File
@@ -0,0 +1,84 @@
<?php
require_once 'functions/space.php';
/*
* Generates a card for a given Space.
*
* @* @param int $space_id "Retrieves information from the database using this ID."
* @* @param int $user_id "Retrieves information from the database using this ID. Default is -1, which will limit queries to a singular Open Space."
* @* @return String $html "The HTML card component for a Space."
*/
function spaceCard(Space $space)
{
$html = '';
return $html;
}
/*
* Generates a card for a given Thought.
*
* @* @param Integer $post_id "Retrieves information from the database using this ID."
* @* @return String $html "The HTML card component for a Thought."
*/
function thoughtCard(Post $post)
{
$html = '';
return $html;
}
/*
* Generates a card for a given Moment.
*
* @* @param Integer $post_id "Retrieves information from the database using this ID."
* @* @return String $html "The HTML card component for a Moment."
*/
function momentCard(Post $post)
{
$html = '';
return $html;
}
/*
* Generates a card for a given Moment.
*
* @* @param Integer $post_id "Retrieves information from the database using this ID."
* @* @return String $html "The HTML card component for a Moment."
*/
function essayCard(Post $post)
{
$topics_html = '';
$topics_html .= '<div class="tag_row">';
$topics_html .= '<a href="/articles.php?view=filter&tag=' . $row['tag'] . '" class="tag">#' . $row['tag'] . '</a>';
$topics_html .= '</div>';
$author = exec_stmt('SELECT user_id, username, profile_picture FROM user WHERE user_id = ?', 'i', $essay['author_id'])->fetch_assoc();
$html = '
<div class="article_card">
<div class="row_space_between">
<h4>' . $essay['title'] . '</h4>
<div class="card_info_box">
<div class="col-right">
<a href="user.php?view=display&user_id=' . $author['user_id'] . '">Written by: ' . $author['username'] . '</a>
<small>Published: ' . $essay['published_at'] . '</small>
<small>Updated: ' . $essay['updated_at'] . '</small>
</div>
<div class="card_pic_box">
<img src="' . $_ENV['PROFILE_IMAGES_PQ_PATH'] . $author['profile_picture'] . '" class="card_profile_picture">
</div>
</div>
</div>
<div class="line"></div>
<p>' . $essay['excerpt'] . '</p>
<div class="row_space_between">
<a href="/articles.php?view=read&article_id=' . $essay['article_id'] . '" class="button">Read More</a>
<div class="tag_box">' . $topics_html . '</div>
</div>
</div>
';
return $html;
}
@@ -7,11 +7,11 @@
<link rel="stylesheet" href="https://nerdfonts.com/assets/css/webfont.css"> <link rel="stylesheet" href="https://nerdfonts.com/assets/css/webfont.css">
<link rel="icon" type="image/x-icon" href="data/vintagecoding-favicon.png"> <link rel="icon" type="image/x-icon" href="data/vintagecoding-favicon.png">
<?php <?php
if (!isset($_SESSION['theme']) || $_SESSION['theme'] == 'dark') if (!isset($_SESSION['theme']) || $_SESSION['theme'] == 'dark')
echo '<link href="css/dark.css" rel="stylesheet" type="text/css">'; echo '<link href="css/dark.css" rel="stylesheet" type="text/css">';
else else
echo '<link href="css/light.css" rel="stylesheet" type="text/css">'; echo '<link href="css/light.css" rel="stylesheet" type="text/css">';
?> ?>
</head> </head>
+13 -13
View File
@@ -6,20 +6,20 @@ $nav = '
<div class="site_navigation"> <div class="site_navigation">
<ul> <ul>
<li><a href="/index.php"' . ($current_page == '/index.php' ? ' class="underline"' : '') . '>Home</a></li> <li><a href="/index.php"' . ($current_page == '/index.php' ? ' class="underline"' : '') . '>Home</a></li>
<li><a href="/articles.php?view=sort&sort=newest"' . ($current_page == '/articles.php' ? ' class="underline"' : '') . '>Articles</a></li> <li><a href="/spaces.php?view=displaySpaces"' . ($current_page == '/spaces.php' ? ' class="underline"' : '') . '>Spaces</a></li>
'; ';
if (isset($_SESSION['role_id']) && $_SESSION['role_id'] <= admin) { if (isset($_SESSION['user'])) {
$nav .= ' $nav .= '
<li><a href="/admin.php?view=users"' . ($current_page == '/admin.php' ? ' class="underline"' : '') . '>Admin</a></li>
';
}
if (isset($_SESSION['user_id'])) {
$nav .= '
<li><a href="/user.php?view=display&user_id=' . $_SESSION['user_id'] . '"' . ($current_page == '/user.php' ? ' class="underline"' : '') . '>Account</a></li> <li><a href="/user.php?view=display&user_id=' . $_SESSION['user_id'] . '"' . ($current_page == '/user.php' ? ' class="underline"' : '') . '>Account</a></li>
<li><a href="/todo.php?view=display"' . ($current_page == '/todo.php' ? ' class="underline"' : '') . '>Todos</a></li> <li><a href="/todo.php?view=display"' . ($current_page == '/todo.php' ? ' class="underline"' : '') . '>Todos</a></li>
'; ';
if ($_SESSION['user'] <= developer) {
$nav .= '
<li><a href="/admin.php?view=users"' . ($current_page == '/admin.php' ? ' class="underline"' : '') . '>Admin</a></li>
';
}
} }
$nav .= ' $nav .= '
@@ -31,9 +31,9 @@ $nav .= '
$theme_toggle = ''; $theme_toggle = '';
if ($_SESSION['theme'] == 'dark') if ($_SESSION['theme'] == 'dark')
$theme_toggle = 'light'; $theme_toggle = 'light';
else else
$theme_toggle = 'dark'; $theme_toggle = 'dark';
$nav .= ' $nav .= '
<button id="theme_toggle" value="' . $theme_toggle . '" name="theme"><i class="nf ' . ($theme_toggle == 'dark' ? 'nf-oct-moon' : 'nf-oct-sun') . '"></i></button> <button id="theme_toggle" value="' . $theme_toggle . '" name="theme"><i class="nf ' . ($theme_toggle == 'dark' ? 'nf-oct-moon' : 'nf-oct-sun') . '"></i></button>
@@ -41,11 +41,11 @@ $nav .= '
'; ';
if (isset($_SESSION['username'])) { if (isset($_SESSION['username'])) {
$nav .= ' $nav .= '
<a href="user.php?view=logout">Log Out</a> <a href="user.php?view=logout">Log Out</a>
'; ';
} else { } else {
$nav .= ' $nav .= '
<a href="user.php?view=login">Login</a> <a href="user.php?view=login">Login</a>
<a href="user.php?view=signup">Sign Up</a> <a href="user.php?view=signup">Sign Up</a>
'; ';
+1 -1
View File
@@ -1,5 +1,5 @@
<?php <?php
include_once 'components/article/card.php'; include_once 'components/core/card.php';
function user_view($user_id) function user_view($user_id)
{ {
-233
View File
@@ -1,233 +0,0 @@
<?php
require_once 'db.php';
require_once 'article_functions.php';
use Postmark\PostmarkClient;
function follow_user($follower_user_id, $following_user_id, $notify)
{
exec_stmt('INSERT INTO follows (follower_user_id, following_user_id, notify_user) VALUES (?, ?, ?)', 'iii', $follower_user_id, $following_user_id, $notify);
}
function unfollow_user($follower_user_id, $following_user_id)
{
exec_stmt('DELETE FROM follows WHERE follower_user_id = ? AND following_user_id = ?', 'ii', $follower_user_id, $following_user_id);
}
function user_id_exists($user_id)
{
$result = exec_stmt('SELECT user_id FROM user WHERE user_id = ?', 'i', $user_id)->fetch_assoc();
if ($result == null)
return false;
return true;
}
function username_exists($username)
{
$result = exec_stmt('SELECT username FROM user WHERE username = ?', 'i', $username)->fetch_assoc();
if ($result == null)
return false;
return true;
}
function logout()
{
foreach (array_keys($_SESSION) as $key)
unset($_SESSION[$key]);
header('Location: .');
}
function create_user($username, $email, $password, $verify_password, $role_id, $upload = null, $x = null, $y = null, $crop_width = null, $honeypot = null)
{
if (!empty($honeypot)) {
$error_msg = '
<div class="error">
You are a bot. Leave now.
</div>
';
return $error_msg;
}
if ($password != $verify_password) {
$error_msg = '
<div class="error">
Passwords do not match!
</div>
';
return $error_msg;
}
if (username_exists($username)) {
$error_msg = '
<div class="error">
Username is taken!
</div>
';
}
$password_hash = hash('sha256', $password);
$id = rand(1000, 999999999);
while (user_id_exists($id))
$id = rand(1000, 999999999);
if (!empty($upload['profile_picture']['tmp_name'])) {
$orig_size = getimagesize($upload['profile_picture']['tmp_name']);
$orig_width = $orig_size[0];
$orig_height = $orig_size[1];
$crop_size = min($orig_width, $orig_height) * 0.56;
crop_image($upload['profile_picture']['tmp_name'], $x, $y, $crop_size, $crop_size);
$target = upload($upload['profile_picture'], $_ENV['PROFILE_IMAGES_FQ_PATH'], $id);
} else {
$target = 'default-profile.png';
}
exec_stmt('INSERT INTO user (user_id, username, email, password_hash, role_id, profile_picture) VALUES (?, ?, ?, ?, ?, ?)', 'isssis', $id, $username, $email, $password_hash, $role_id, $target);
return true;
}
function reset_password($user_id, $new_password, $verify_new_password)
{
if ($new_password != $verify_new_password) {
$error_msg = '
<div class="error">
Passwords do not match!
</div>
';
return $error_msg;
}
$password_hash = hash('sha256', $new_password);
exec_stmt('UPDATE user SET password_hash = ? WHERE user_id = ?', 'si', $password_hash, $user_id);
}
function update_email($user_id, $new_email, $verify_new_email)
{
if ($new_email != $verify_new_email) {
$error_msg = '
<div class="error">
Emails do not match!
</div>
';
return $error_msg;
}
exec_stmt('UPDATE user SET email = ? WHERE user_id = ?', 'si', $new_email, $user_id);
}
function update_username($user_id, $new_username, $verify_new_username)
{
if ($new_username != $verify_new_username) {
$error_msg = '
<div class="error">
Usernames do not match!
</div>
';
return $error_msg;
}
if ($result = username_exists($new_username))
return $result;
exec_stmt('UPDATE user SET username = ? WHERE user_id = ?', 'si', $new_username, $user_id);
}
function update_profile_picture($user_id) {}
function request_role_change($user_id, $new_role_id)
{
$user = exec_stmt('SELECT user_id, username, email, roles.role, created_at FROM user INNER JOIN roles WHERE user.role_id = roles.role_id AND user_id = ?', 'i', $user_id)->fetch_assoc();
$new_role = exec_stmt('SELECT role FROM roles WHERE role_id = ?', 'i', $new_role_id)->fetch_array();
$email_html = '
<h1>User Role Change Request</h1>
<p>The following user has requested their role on vintagecoding.net to be changed:</p>
<p><strong>User ID: </strong>' . $user['user_id'] . '</p>
<p><strong>Username: </strong>' . $user['username'] . '</p>
<p><strong>Email: </strong>' . $user['email'] . '</p>
<p><strong>Current Role: </strong>' . $user['role'] . ' -> ' . $new_role[0] . '</p>
<p><strong>Created At: </strong>' . $user['created_at'] . '</p>
';
$client = new PostmarkClient($_ENV['POSTMARK_API_TOKEN']);
$client->sendEmail(
'mailer@joshashton.dev',
'me@joshashton.dev',
'User Role Change Request - ' . $user['user_id'],
$email_html
);
}
function delete_account($user_id, $password = null, $verify_password = null)
{
if ($user_id == 2025) {
$error_msg = '
<div class="error">
I am the owner, and I cannot delete myself...
</div>
';
return $error_msg;
}
if ($password != $verify_password) {
$error_msg = '
<div class="error">
Passwords do not match!
</div>
';
return $error_msg;
}
if ($password && $verify_password) {
$password_hash = hash('sha256', $password);
$user = exec_stmt('SELECT user_id, profile_picture FROM user WHERE user_id = ? AND password_hash = ?', 'is', $user_id, $password_hash)->fetch_assoc();
if ($user == null) {
$error_msg = '
<div class="error">
Username and/or Password are invalid!
</div>
';
return $error_msg;
}
// Hardcoded prevention of deleting the owner's profile picture
if (!empty($user['profile_picture']) && $user['profile_picture'] != '2025.jpg')
delete_file($_ENV['PROFILE_IMAGES_FQ_PATH'] . $user['profile_picture']);
exec_stmt('DELETE FROM user WHERE user_id = ?', 'i', $user_id);
logout();
} else if ($_SESSION['role_id'] <= admin) {
$user = exec_stmt('SELECT user_id, profile_picture FROM user WHERE user_id = ?', 'i', $user_id)->fetch_assoc();
if ($user['user_id'] == 2025) {
$error_msg = '
<div class="error">
Cannot delete the owner!
</div>
';
return $error_msg;
}
// Hardcoded prevention of deleting the owner's profile picture
if (!empty($user['profile_picture']) && $user['profile_picture'] != '2025.jpg')
delete_file($_ENV['PROFILE_IMAGES_FQ_PATH'] . $user['profile_picture']);
exec_stmt('DELETE FROM user WHERE user_id = ?', 'i', $user_id);
}
}
-134
View File
@@ -1,134 +0,0 @@
<?php
require_once 'db.php';
function article_ids_by_newest()
{
$conn = get_connection();
$results = $conn->query('SELECT article_id FROM article ORDER BY published_at DESC');
$ids = [];
while ($row = $results->fetch_assoc())
$ids[] = $row['article_id'];
return $ids;
}
function article_ids_by_oldest()
{
$conn = get_connection();
$results = $conn->query('SELECT article_id FROM article ORDER BY published_at ASC');
$ids = [];
while ($row = $results->fetch_assoc())
$ids[] = $row['article_id'];
return $ids;
}
function articles_ids_by_author($author_id)
{
$results = exec_stmt('SELECT article_id FROM article WHERE author_id = ? ORDER BY published_at DESC', 'i', $author_id);
$ids = [];
while ($row = $results->fetch_assoc())
$ids[] = $row['article_id'];
return $ids;
}
function article_tags()
{
$conn = get_connection();
$results = $conn->query('SELECT * FROM tags');
$tags = [];
while ($row = $results->fetch_assoc())
$tags[] = $row;
return $tags;
}
function article_ids_by_tag($tag)
{
$results = exec_stmt('SELECT article_id FROM article WHERE article.article_id IN ( SELECT article_tags.article_id FROM article_tags INNER JOIN tags ON article_tags.tag_id = tags.tag_id WHERE tag = ?) ORDER BY published_at DESC', 's', $tag);
$ids = [];
while ($row = $results->fetch_assoc())
$ids[] = $row['article_id'];
return $ids;
}
function create_article($author_id, $title, $excerpt, $tags, $markdown_file_contents)
{
$id = exec_stmt('INSERT INTO article (author_id, title, excerpt, published_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)', 'iss', $author_id, $title, $excerpt);
$path = $_ENV['ARTICLES_FQ_PATH'] . $id . '/';
mkdir($path);
$fs = fopen($path . 'article.md', 'a');
fwrite($fs, $markdown_file_contents);
fclose($fs);
if (count($tags) > 0) {
$tag_insert_stmt = 'INSERT INTO article_tags (article_id, tag_id) VALUES ';
$types = '';
for ($i = 0; $i < count($tags); $i++) {
$tag_insert_stmt .= '(' . $id . ', ?)';
if ($i < count($tags) - 1)
$tag_insert_stmt .= ', ';
$types .= 'i';
}
exec_stmt($tag_insert_stmt, $types, ...$tags);
}
return $id;
}
function update_article($article_id, $title, $excerpt, $tags, $markdown_file_contents)
{
exec_stmt('UPDATE article SET title = ?, excerpt = ?, updated_at = CURRENT_TIMESTAMP WHERE article_id = ?', 'ssi', $title, $excerpt, $article_id);
$path = $_ENV['ARTICLES_FQ_PATH'] . $article_id . '/';
$fs = fopen($path . 'article.md', 'w');
fwrite($fs, $markdown_file_contents);
fclose($fs);
exec_stmt('DELETE from article_tags WHERE article_id = ?', 'i', $article_id);
if (count($tags) > 0) {
$tag_insert_stmt = 'INSERT INTO article_tags (article_id, tag_id) VALUES ';
$types = '';
for ($i = 0; $i < count($tags); $i++) {
$tag_insert_stmt .= '(' . $article_id . ', ?)';
if ($i < count($tags) - 1)
$tag_insert_stmt .= ', ';
$types .= 'i';
}
exec_stmt($tag_insert_stmt, $types, ...$tags);
}
}
function delete_article($article_id)
{
exec_stmt('DELETE FROM article_tags WHERE article_id = ?', 'i', $article_id);
exec_stmt('DELETE FROM article WHERE article_id = ?', 'i', $article_id);
delete_dir($_ENV['ARTICLES_FQ_PATH'] . $article_id . '/');
}
function delete_articles_by_author($author_id)
{
exec_stmt('DELETE FROM article WHERE author_id = ?', 'i', $author_id);
}
function increment_read_counter($article_id)
{
exec_stmt('UPDATE article SET read_count = read_count + 1 WHERE article_id = ?', 'i', $article_id);
}
+20 -2
View File
@@ -1,11 +1,30 @@
<?php <?php
include_once 'functions/init.php'; require_once 'init.php';
use Postmark\PostmarkClient; use Postmark\PostmarkClient;
if (!empty($_FILES['upload'])) if (!empty($_FILES['upload']))
upload($_FILES['upload'], $_ENV['UPLOAD_TMP_FQ_PATH'], isset($_POST['filename']) ? $_POST['filename'] : null); upload($_FILES['upload'], $_ENV['UPLOAD_TMP_FQ_PATH'], isset($_POST['filename']) ? $_POST['filename'] : null);
/*
* @* @param int $mode 0 for User, 1 for Post, 2 for Space
*/
function randomId(int $mode)
{
$id = rand(1000, 999999999999);
switch ($mode) {
case 0:
if (User::exists($id))
return randomId($mode);
else
return $id;
case 1:
break;
case 2:
break;
}
}
function notify_users($emails, $auther_username, $article_id, $article_title, $excerpt) function notify_users($emails, $auther_username, $article_id, $article_title, $excerpt)
{ {
foreach ($emails as $email) { foreach ($emails as $email) {
@@ -16,7 +35,6 @@ function notify_users($emails, $auther_username, $article_id, $article_title, $e
<p>' . $excerpt . '</p> <p>' . $excerpt . '</p>
'; ';
$client = new PostmarkClient($_ENV['POSTMARK_API_TOKEN']); $client = new PostmarkClient($_ENV['POSTMARK_API_TOKEN']);
$client->sendEmail( $client->sendEmail(
+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
require_once 'vendor/autoload.php'; require_once 'vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..'); $dotenv = Dotenv\Dotenv::createImmutable('/home/jashton/dev/me/weave.space/');
$dotenv->safeLoad(); $dotenv->safeLoad();
// Use environment variables for the database password and IP address. // Use environment variables for the database password and IP address.
+6 -5
View File
@@ -5,10 +5,12 @@ header("Content-Security-Policy: default-src 'self' localhost:7477; font-src 'se
// Initialize Composer. // Initialize Composer.
require_once 'vendor/autoload.php'; require_once 'vendor/autoload.php';
require_once 'db.php'; require_once 'db.php';
require_once 'account_functions.php'; require_once 'user.php';
require_once 'post.php';
require_once 'spaces.php';
// Load environment variables. // Load environment variables.
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../'); $dotenv = Dotenv\Dotenv::createImmutable('/home/jashton/dev/me/weave.space/.env');
$dotenv->safeLoad(); $dotenv->safeLoad();
// Set SESSION variable. // Set SESSION variable.
@@ -21,9 +23,8 @@ if (!isset($_SESSION['initialized'])) {
} }
define('owner', 1); define('owner', 1);
define('admin', 2); define('developer', 2);
define('contributor', 3); define('user', 3);
define('reader', 4);
// Update the database and cookies to keep the user logged in. // Update the database and cookies to keep the user logged in.
if (isset($_SESSION['user_id'])) { if (isset($_SESSION['user_id'])) {
+805
View File
@@ -0,0 +1,805 @@
<?php
// It's good practice to use namespaces, e.g.:
// namespace VintageCoding\Models;
// namespace VintageCoding\Repositories;
require_once 'db.php'; // For exec_stmt() and get_connection() (though get_connection is used by exec_stmt)
require_once 'user.php'; // For User and Author classes
require_once 'space.php';
// -- Data Transfer Object for Topics --
class Topic
{
public int $topicId;
public string $name;
public function __construct(int $topicId, string $name)
{
$this->topicId = $topicId;
$this->name = $name;
}
public function getId(): int
{
return $this->topicId;
}
public function getName(): string
{
return $this->name;
}
}
// -- Post Data Models (as previously defined, with setReadCount in Post) --
class Post
{
private Space $space;
private Author $author;
private int $postTypeId;
private ?string $title;
private ?string $slug;
private int $readCount;
private ?DateTime $createdAt;
private ?DateTime $updatedAt;
private ?DateTime $status;
private bool $published;
public function __construct($postArray)
{
$this->postId = $postArray['postId'];
$this->author = $postArray['authorId'];
$this->postTypeId = $postArray['postType'];
$this->title = $postArray['title'];
$this->slug = $postArray['slug'];
$this->readCount = 0;
$this->status = $postArray['status'];
}
public function getPostId(): int
{
return $this->postId;
}
public function getAuthor(): Author
{
return $this->author;
}
public function getPostTypeId(): int
{
return $this->postTypeId;
}
public function getTitle(): ?string
{
return $this->title;
}
public function getSlug(): ?string
{
return $this->slug;
}
public function getReadCount(): int
{
return $this->readCount;
}
public function getCreatedAt(): ?DateTime
{
return $this->createdAt;
}
public function getUpdatedAt(): ?DateTime
{
return $this->updatedAt;
}
public function getStatus(): ?DateTime
{
return $this->status;
}
public function isPublished(): bool
{
return $this->published;
}
public function incrementReadCount(): void
{
$this->readCount++;
}
public function setSlug(?string $slug): void
{
$this->slug = $slug;
}
public function setStatus(?string $status): void
{
$this->status = $status;
}
public function setPublished(bool $published): void
{
$this->published = $published;
}
public function setReadCount(int $count): void
{
$this->readCount = $count;
}
}
class Essay extends Post
{
private ?string $markdown;
private ?string $excerpt;
private ?array $files;
public function __construct(int $postId, Author $author, ?string $title = null, ?string $slug = null, ?string $markdown = null, ?string $excerpt = null, ?array $files = null, ?DateTime $createdAt = null, ?DateTime $updatedAt = null, ?DateTime $publishedAt = null, bool $published = true)
{
parent::__construct($postId, $author, 1, $title, $slug, $createdAt, $updatedAt, $publishedAt, $published);
$this->markdown = $markdown;
$this->excerpt = $excerpt;
$this->files = $files;
}
public function getMarkdown(): ?string
{
return $this->markdown;
}
public function getExcerpt(): ?string
{
return $this->excerpt;
}
public function getFiles(): ?array
{
return $this->files;
}
public function setMarkdown(?string $markdown): void
{
$this->markdown = $markdown;
}
public function setExcerpt(?string $excerpt): void
{
$this->excerpt = $excerpt;
}
public function setFiles(?array $files): void
{
$this->files = $files;
}
}
class Moment extends Post
{
private ?string $caption;
private ?array $images;
public function __construct(int $postId, Author $author, ?string $caption = null, ?array $images = null, ?DateTime $createdAt = null, ?DateTime $updatedAt = null, ?DateTime $publishedAt = null, bool $published = true)
{
parent::__construct($postId, $author, 2, null, null, $createdAt, $updatedAt, $publishedAt, $published);
$this->caption = $caption;
$this->images = $images;
}
public function getCaption(): ?string
{
return $this->caption;
}
public function getImages(): ?array
{
return $this->images;
}
public function setCaption(?string $caption): void
{
$this->caption = $caption;
}
public function setImages(?array $images): void
{
$this->images = $images;
}
}
class Thought extends Post
{
private ?string $thought;
public function __construct(int $postId, Author $author, ?string $thought = null, ?DateTime $createdAt = null, ?DateTime $updatedAt = null, ?DateTime $publishedAt = null, bool $published = true)
{
parent::__construct($postId, $author, 3, null, null, $createdAt, $updatedAt, $publishedAt, $published);
$this->thought = $thought;
}
public function getThought(): ?string
{
return $this->thought;
}
public function setThought(?string $thought): void
{
$this->thought = $thought;
}
}
// -- Repository for Topics --
class TopicRepository
{
// No $conn property needed as exec_stmt handles connection
public function __construct()
{
// Constructor is now empty
}
public function getAllTopics(): array
{
$result = exec_stmt('SELECT topic_id, topic FROM topics ORDER BY topic ASC', ''); // No params
$topics = [];
if ($result instanceof mysqli_result) {
while ($row = $result->fetch_assoc()) {
$topics[] = new Topic((int) $row['topic_id'], $row['topic']);
}
$result->free();
} else {
// exec_stmt for SELECT should return mysqli_result. If not, it's an issue with exec_stmt or query.
error_log('TopicRepository::getAllTopics expected mysqli_result, got something else.');
}
return $topics;
}
public function findById(int $topicId): ?Topic
{
$result = exec_stmt('SELECT topic_id, topic FROM topics WHERE topic_id = ?', 'i', $topicId);
if ($result instanceof mysqli_result) {
if ($row = $result->fetch_assoc()) {
$result->free();
return new Topic((int) $row['topic_id'], $row['topic']);
}
$result->free();
}
return null;
}
public function findByName(string $name): ?Topic
{
$result = exec_stmt('SELECT topic_id, topic FROM topics WHERE topic = ?', 's', $name);
if ($result instanceof mysqli_result) {
if ($row = $result->fetch_assoc()) {
$result->free();
return new Topic((int) $row['topic_id'], $row['topic']);
}
$result->free();
}
return null;
}
public function findOrCreate(string $name): Topic
{
$topic = $this->findByName($name);
if ($topic) {
return $topic;
}
// topics.topic_id is AUTO_INCREMENT, so exec_stmt should return the new ID.
$newTopicId = exec_stmt('INSERT INTO topics (topic) VALUES (?)', 's', $name);
if (is_numeric($newTopicId) && $newTopicId > 0) {
return new Topic((int) $newTopicId, $name);
}
// If $newTopicId is 0 or not numeric, insert failed or exec_stmt behavior is unexpected.
throw new \RuntimeException('Failed to create topic or retrieve its ID: ' . $name);
}
}
// -- Repository for Posts --
class PostRepository
{
private TopicRepository $topicRepository;
private string $essayContentPath;
public function __construct(TopicRepository $topicRepository, string $essayContentPath)
{
$this->topicRepository = $topicRepository;
$this->essayContentPath = rtrim($essayContentPath, '/') . '/';
}
private function getAuthorFromDb(int $authorId): ?Author
{
$result = exec_stmt('SELECT user_id, username, profile_picture, bio, website FROM user WHERE user_id = ?', 'i', $authorId);
if ($result instanceof mysqli_result) {
$authorData = $result->fetch_assoc();
$result->free();
if ($authorData) {
return new Author(
(int) $authorData['user_id'],
$authorData['username'],
$authorData['profile_picture'],
$authorData['bio'],
$authorData['website']
);
}
} else {
error_log('PostRepository::getAuthorFromDb expected mysqli_result for author ID: ' . $authorId);
}
error_log('Author not found with ID: ' . $authorId);
return null;
}
private function hydratePost(array $row): ?Post
{
$author = $this->getAuthorFromDb((int) $row['author_id']);
if (!$author)
return null;
$postId = (int) $row['post_id'];
$postTypeId = (int) $row['post_type'];
$title = $row['title'];
$slug = $row['slug'];
$readCount = (int) $row['read_count'];
$createdAt = $row['created_at'] ? new DateTime($row['created_at']) : null;
$updatedAt = $row['updated_at'] ? new DateTime($row['updated_at']) : null;
$publishedAt = $row['published_at'] ? new DateTime($row['published_at']) : null;
$published = (bool) $row['published'];
$post = null;
switch ($postTypeId) {
case 1: // Essay
$markdown = $this->getEssayMarkdown($postId);
$post = new Essay($postId, $author, $title, $slug, $markdown, $row['excerpt'], null, $createdAt, $updatedAt, $publishedAt, $published);
break;
case 2: // Moment
$post = new Moment($postId, $author, $row['excerpt'], null, $createdAt, $updatedAt, $publishedAt, $published);
break;
case 3: // Thought
$post = new Thought($postId, $author, $row['excerpt'], $createdAt, $updatedAt, $publishedAt, $published);
break;
default:
error_log('Unknown post type ID: ' . $postTypeId);
return null;
}
if ($post)
$post->setReadCount($readCount);
return $post;
}
// File operations remain unchanged as they don't use the DB connection directly
private function getEssayMarkdown(int $postId): ?string
{ /* ... same as before ... */
$filePath = $this->essayContentPath . $postId . '/article.md';
if (file_exists($filePath) && is_readable($filePath)) {
return file_get_contents($filePath);
}
return null;
}
private function saveEssayMarkdown(int $postId, string $markdownContent): bool
{ /* ... same as before ... */
$dirPath = $this->essayContentPath . $postId . '/';
if (!is_dir($dirPath)) {
if (!mkdir($dirPath, 0755, true)) {
error_log('Failed to create directory: ' . $dirPath);
return false;
}
}
$filePath = $dirPath . 'article.md';
if (file_put_contents($filePath, $markdownContent) === false) {
error_log('Failed to write markdown file: ' . $filePath);
return false;
}
return true;
}
private function deleteEssayContentDirectory(int $postId): bool
{ /* ... same as before ... */
$dirPath = $this->essayContentPath . $postId . '/';
if (!is_dir($dirPath))
return true;
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dirPath, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $fileinfo) {
$todo = ($fileinfo->isDir() ? 'rmdir' : 'unlink');
if (!@$todo($fileinfo->getRealPath())) {
error_log("Failed to {$todo} {$fileinfo->getRealPath()}");
return false;
}
}
if (!@rmdir($dirPath)) {
error_log("Failed to remove main directory {$dirPath}");
return false;
}
return true;
}
public function findById(int $postId): ?Post
{
$result = exec_stmt('SELECT * FROM posts WHERE post_id = ?', 'i', $postId);
if ($result instanceof mysqli_result) {
$row = $result->fetch_assoc();
$result->free();
return $row ? $this->hydratePost($row) : null;
}
error_log('PostRepository::findById expected mysqli_result for post ID: ' . $postId);
return null;
}
public function getPostIds(string $orderBy = 'published_at', string $direction = 'DESC'): array
{
// Basic validation for orderBy and direction
$orderBy = preg_replace('/[^a-zA-Z0-9_]/', '', $orderBy); // Basic sanitization
$direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC';
$sql = "SELECT post_id FROM posts ORDER BY $orderBy $direction";
$result = exec_stmt($sql, '');
$ids = [];
if ($result instanceof mysqli_result) {
while ($row = $result->fetch_assoc())
$ids[] = (int) $row['post_id'];
$result->free();
} else {
error_log('Error in getPostIds: Expected mysqli_result.');
}
return $ids;
}
public function getAllPosts(string $orderBy = 'published_at', string $direction = 'DESC'): array
{
$orderBy = preg_replace('/[^a-zA-Z0-9_]/', '', $orderBy);
$direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC';
$sql = "SELECT * FROM posts ORDER BY $orderBy $direction";
$result = exec_stmt($sql, '');
$posts = [];
if ($result instanceof mysqli_result) {
while ($row = $result->fetch_assoc()) {
if ($postObject = $this->hydratePost($row))
$posts[] = $postObject;
}
$result->free();
} else {
error_log('Error in getAllPosts: Expected mysqli_result.');
}
return $posts;
}
public function getPostIdsByAuthor(int $authorId, string $orderBy = 'published_at', string $direction = 'DESC'): array
{
$orderBy = preg_replace('/[^a-zA-Z0-9_]/', '', $orderBy);
$direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC';
$sql = "SELECT post_id FROM posts WHERE author_id = ? ORDER BY $orderBy $direction";
$result = exec_stmt($sql, 'i', $authorId);
$ids = [];
if ($result instanceof mysqli_result) {
while ($row = $result->fetch_assoc())
$ids[] = (int) $row['post_id'];
$result->free();
} else {
error_log('Error in getPostIdsByAuthor: Expected mysqli_result.');
}
return $ids;
}
public function getPostsByAuthor(int $authorId, string $orderBy = 'published_at', string $direction = 'DESC'): array
{
$orderBy = preg_replace('/[^a-zA-Z0-9_.]/', '', $orderBy); // Allow dot for aliased columns
$direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC';
$sql = "SELECT * FROM posts WHERE author_id = ? ORDER BY $orderBy $direction";
$result = exec_stmt($sql, 'i', $authorId);
$posts = [];
if ($result instanceof mysqli_result) {
while ($row = $result->fetch_assoc()) {
if ($postObject = $this->hydratePost($row))
$posts[] = $postObject;
}
$result->free();
} else {
error_log('Error in getPostsByAuthor: Expected mysqli_result.');
}
return $posts;
}
public function getPostIdsByTopicName(string $topicName, string $orderBy = 'p.published_at', string $direction = 'DESC'): array
{
$orderBy = preg_replace('/[^a-zA-Z0-9_.]/', '', $orderBy);
$direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC';
$sql = "SELECT p.post_id FROM posts p
INNER JOIN post_topics pt ON p.post_id = pt.post_id
INNER JOIN topics t ON pt.topic_id = t.topic_id
WHERE t.topic = ?
ORDER BY $orderBy $direction";
$result = exec_stmt($sql, 's', $topicName);
$ids = [];
if ($result instanceof mysqli_result) {
while ($row = $result->fetch_assoc())
$ids[] = (int) $row['post_id'];
$result->free();
} else {
error_log('Error in getPostIdsByTopicName: Expected mysqli_result.');
}
return $ids;
}
public function getPostsByTopicName(string $topicName, string $orderBy = 'p.published_at', string $direction = 'DESC'): array
{
$orderBy = preg_replace('/[^a-zA-Z0-9_.]/', '', $orderBy);
$direction = strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC';
$sql = "SELECT p.* FROM posts p
INNER JOIN post_topics pt ON p.post_id = pt.post_id
INNER JOIN topics t ON pt.topic_id = t.topic_id
WHERE t.topic = ?
ORDER BY $orderBy $direction";
$result = exec_stmt($sql, 's', $topicName);
$posts = [];
if ($result instanceof mysqli_result) {
while ($row = $result->fetch_assoc()) {
if ($postObject = $this->hydratePost($row))
$posts[] = $postObject;
}
$result->free();
} else {
error_log('Error in getPostsByTopicName: Expected mysqli_result.');
}
return $posts;
}
private function updatePostTopics(int $postId, array $topicNames): void
{
// Delete existing topics for the post
// exec_stmt returns 0 for successful DELETE. No direct success check here.
exec_stmt('DELETE FROM post_topics WHERE post_id = ?', 'i', $postId);
if (empty($topicNames))
return;
// Batch insert is harder with this exec_stmt. Doing one by one.
// This is inefficient but simpler with the current exec_stmt.
foreach ($topicNames as $name) {
$topicObj = $this->topicRepository->findOrCreate(trim($name));
// post_topics has no auto-increment, exec_stmt will return 0 for success.
exec_stmt('INSERT INTO post_topics (post_id, topic_id) VALUES (?, ?)', 'ii', $postId, $topicObj->getId());
}
}
public function createPost(Post $post, array $topicNames = []): ?Post
{
// WARNING: No transaction possible with the provided exec_stmt for multi-step operations.
// Each exec_stmt is its own transaction.
try {
$sql = 'INSERT INTO posts (post_id, author_id, post_type, title, slug, read_count, created_at, updated_at, published_at, published, excerpt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
$postIdVal = $post->getPostId();
$authorId = $post->getAuthor()->getUserId();
$postTypeId = $post->getPostTypeId();
$title = $post->getTitle();
$slug = $post->getSlug();
$readCount = $post->getReadCount();
$createdAtDb = $post->getCreatedAt() ? $post->getCreatedAt()->format('Y-m-d H:i:s') : (new DateTime())->format('Y-m-d H:i:s');
$updatedAtDb = $post->getUpdatedAt() ? $post->getUpdatedAt()->format('Y-m-d H:i:s') : (new DateTime())->format('Y-m-d H:i:s');
$publishedAtDb = $post->getPublishedAt() ? $post->getPublishedAt()->format('Y-m-d H:i:s') : null;
$published = (int) $post->isPublished();
$excerpt = null;
if ($post instanceof Essay)
$excerpt = $post->getExcerpt();
elseif ($post instanceof Moment)
$excerpt = $post->getCaption();
elseif ($post instanceof Thought)
$excerpt = $post->getThought();
// For INSERT on `posts` (no auto-increment PK), exec_stmt returns 0 on success.
$insertResult = exec_stmt(
$sql,
'iiississsis',
$postIdVal,
$authorId,
$postTypeId,
$title,
$slug,
$readCount,
$createdAtDb,
$updatedAtDb,
$publishedAtDb,
$published,
$excerpt
);
// We can't reliably check $insertResult for success of this specific insert.
// Assume success if no PHP error/exception was thrown by exec_stmt (though it doesn't throw).
if ($post instanceof Essay && $post->getMarkdown() !== null) {
if (!$this->saveEssayMarkdown($postIdVal, $post->getMarkdown())) {
// Rollback is not possible. Log error.
error_log('Failed to save essay markdown for post ID: ' . $postIdVal . '. DB insert was separate.');
// Potentially delete the created post record if consistency is critical, but that's complex.
}
}
$this->updatePostTopics($postIdVal, $topicNames); // Also not part of a transaction
return $this->findById($postIdVal);
} catch (\Exception $e) { // Catch any exceptions from our code, not exec_stmt
error_log("Error creating post (ID: {$post->getPostId()}): " . $e->getMessage());
return null;
}
}
public function updatePost(Post $post, array $topicNames = []): ?Post
{
// WARNING: No transaction possible.
try {
$sql = 'UPDATE posts SET author_id = ?, post_type = ?, title = ?, slug = ?,
updated_at = CURRENT_TIMESTAMP, published_at = ?, published = ?, excerpt = ?
WHERE post_id = ?';
$authorId = $post->getAuthor()->getUserId();
$postTypeId = $post->getPostTypeId();
$title = $post->getTitle();
$slug = $post->getSlug();
$publishedAtDb = $post->getPublishedAt() ? $post->getPublishedAt()->format('Y-m-d H:i:s') : null;
$published = (int) $post->isPublished();
$postIdVal = $post->getPostId();
$excerpt = null;
if ($post instanceof Essay)
$excerpt = $post->getExcerpt();
elseif ($post instanceof Moment)
$excerpt = $post->getCaption();
elseif ($post instanceof Thought)
$excerpt = $post->getThought();
// exec_stmt returns 0 for successful UPDATE.
exec_stmt(
$sql,
'iisssisi',
$authorId,
$postTypeId,
$title,
$slug,
$publishedAtDb,
$published,
$excerpt,
$postIdVal
);
if ($post instanceof Essay && $post->getMarkdown() !== null) {
if (!$this->saveEssayMarkdown($postIdVal, $post->getMarkdown())) {
error_log('Failed to save essay markdown for post ID: ' . $postIdVal . ' during update.');
}
}
$this->updatePostTopics($postIdVal, $topicNames);
return $this->findById($postIdVal);
} catch (\Exception $e) {
error_log("Error updating post (ID: {$post->getPostId()}): " . $e->getMessage());
return null;
}
}
/**
* Deletes a post.
* WARNING: Due to exec_stmt, this is not a transactional operation.
* Success of individual DB deletions is not reliably checkable.
* This method attempts the operations and logs errors.
* Consider changing return type to void as boolean success is ambiguous.
*/
public function deletePost(int $postId): void
{
try {
$postDataResult = exec_stmt('SELECT post_type FROM posts WHERE post_id = ?', 'i', $postId);
$postType = null;
if ($postDataResult instanceof mysqli_result && $row = $postDataResult->fetch_assoc()) {
$postType = (int) $row['post_type'];
$postDataResult->free();
}
exec_stmt('DELETE FROM post_topics WHERE post_id = ?', 'i', $postId);
exec_stmt('DELETE FROM posts WHERE post_id = ?', 'i', $postId);
// Cannot reliably check if the above deletes were successful or affected rows.
if ($postType === 1) { // Essay
if (!$this->deleteEssayContentDirectory($postId)) {
error_log('Failed to delete essay content directory for post ID: ' . $postId);
}
}
} catch (\Exception $e) {
error_log("Error during deletePost for ID $postId: " . $e->getMessage());
// Depending on desired behavior, re-throw or handle
}
}
/**
* Deletes posts by author.
* WARNING: Not transactional. Returns void as count of deleted posts cannot be reliably obtained from exec_stmt.
*/
public function deletePostsByAuthor(int $authorId): void
{
try {
$result = exec_stmt('SELECT post_id, post_type FROM posts WHERE author_id = ?', 'i', $authorId);
$postsToDelete = [];
if ($result instanceof mysqli_result) {
while ($row = $result->fetch_assoc())
$postsToDelete[] = $row;
$result->free();
} else {
error_log("Failed to fetch posts for deletion by author ID: $authorId");
return; // Exit if we can't get the list of posts
}
foreach ($postsToDelete as $postInfo) {
if ((int) $postInfo['post_type'] === 1) { // Essay
if (!$this->deleteEssayContentDirectory((int) $postInfo['post_id'])) {
error_log('Failed to delete essay content for post ' . $postInfo['post_id'] . ' during mass delete by author.');
}
}
// Handle Moment image deletion if any
}
// Must delete from child table `post_topics` first if no ON DELETE CASCADE, or if ensuring order.
// This is more complex with joins if exec_stmt doesn't handle it well.
// Simpler: iterate and delete topics for each post_id, or a broader delete.
foreach ($postsToDelete as $postInfo) {
exec_stmt('DELETE FROM post_topics WHERE post_id = ?', 'i', (int) $postInfo['post_id']);
}
exec_stmt('DELETE FROM posts WHERE author_id = ?', 'i', $authorId);
// Cannot get actual deleted count.
} catch (\Exception $e) {
error_log("Error deleting posts by author (ID: $authorId): " . $e->getMessage());
}
}
/**
* Increments read count for a post.
* WARNING: Success of the UPDATE is not reliably checkable with current exec_stmt.
*/
public function incrementReadCount(int $postId): void
{
// exec_stmt returns 0 for successful UPDATE.
exec_stmt('UPDATE posts SET read_count = read_count + 1 WHERE post_id = ?', 'i', $postId);
// No easy way to confirm success with current exec_stmt.
}
}
// Example Usage (Illustrative - this would go in your controller/logic files)
/*
* $topicRepo = new TopicRepository();
* // Ensure $_ENV['ESSAY_CONTENT_PATH'] is set in your environment/config
* $essayPath = $_ENV['ESSAY_CONTENT_PATH'] ?? '/path/to/your/site/public/essays';
* $postRepo = new PostRepository($topicRepo, $essayPath);
*
* // --- Fetching Posts ---
* // $allPosts = $postRepo->getAllPosts('created_at', 'DESC');
* // $post123 = $postRepo->findById(123);
* // if ($post123 instanceof Essay) {
* // echo "Essay Title: " . $post123->getTitle() . "\n";
* // }
*
* // --- Creating a new Essay ---
* // $author = $userRepo->findAuthorById(1); // Assuming a UserRepo or similar for authors
* // if ($author) {
* // $newEssayId = 1001; // Must be unique, not auto-incremented in schema
* // $newEssay = new Essay(
* // $newEssayId, $author, "My New OOP Essay", "my-new-oop-essay",
* // "# Hello World\n\nThis is markdown content.", "An excerpt about the essay."
* // );
* // $createdPost = $postRepo->createPost($newEssay, ['PHP', 'OOP', 'Refactoring']);
* // if ($createdPost) {
* // echo "Created Post ID: " . $createdPost->getPostId() . "\n";
* // } else {
* // echo "Failed to create post.\n";
* // }
* // }
*
* // No explicit $dbConnection->close(); needed as exec_stmt handles its own connections.
*/
+78
View File
@@ -0,0 +1,78 @@
<?php
require_once 'core.php';
require_once 'db.php';
require_once 'user.php';
require_once 'post.php';
class Space
{
private int $spaceId;
private string $name;
private string $description;
private array $members;
private array $posts;
private ?Space $parent;
public function __construct(?int $spaceId, string $name, string $description, array $members, $posts, ?Space $parent = null)
{
$this->spaceId = $spaceId ?? randomId(2);
$this->name = $name;
$this->description = $description;
$this->members = $members;
$this->posts[] = $posts;
$this->parent = $parent;
/* $stmt = 'INSERT INTO spaces (spaceId, name, description, parentSpaceIdk)'; */
}
public static function retrieveFromDB(int $space, ?int $user): ?Space
{
if ($user) {
$stmt = 'SELECT * FROM spaceMembers WHERE space = ? AND user = ?';
$isMember = exec_stmt($stmt, 'ii', $space, $user)->fetch_assoc();
if (!$isMember)
return null;
} else {
$stmt = 'SELECT visibility FROM space WHERE id = ?';
$isOpen = exec_stmt($stmt, 'i', $space);
if (!$isOpen)
return null;
}
$stmt = 'SELECT * FROM space WHERE id = ?';
$space = exec_stmt($stmt, 'i', $space)->fetch_assoc();
if (!empty($space['parentSpace']))
$parent = Space::retrieveFromDB($space['parentSpace'], $user);
else
$parent = null;
$stmt = 'SELECT user FROM spaceMembers WHERE space = ?';
$members = exec_stmt($stmt, 'i', $space)->fetch_assoc();
$stmt = 'SELECT post FROM spacePosts WHERE space = ?';
$posts = exec_stmt($stmt, 'i', $space)->fetch_assoc();
return new Space($space['id'], $space['name'], $space['description'], $members, $posts, $parent);
}
public function __toString()
{
echo 'todo: this should be JSON';
}
public function getId(): int
{
return $this->spaceId;
}
public function getName(): string
{
return $this->name;
}
public function getDescription(): string
{
return $this->description;
}
}
-69
View File
@@ -1,69 +0,0 @@
<?php
// class Todo
// {
// private $todo_id;
// private $user_id;
// private $task;
// private $description;
// private $priority;
// private $due_date;
// private $category;
// private $status;
// private $created_at;
// private $update_at;
// private $completed_at;
// private $is_recurring;
// private $recurrence_rule;
// private $reminder_at;
//
// function __construct(
// $todo_id,
// $user_id,
// $task,
// $description,
// $priority,
// $due_date,
// $category,
// $status,
// $created_at,
// $update_at,
// $completed_at,
// $is_recurring,
// $recurrence_rule,
// $reminder_at,
// ) {
// $this->todo_id = $todo_id;
// $this->user_id = $user_id;
// $this->task = $task;
// $this->description = $description;
// $this->priority = $priority;
// $this->due_date = $due_date;
// $this->category = $category;
// $this->status = $status;
// $this->created_at = $created_at;
// $this->update_at = $update_at;
// $this->completed_at = $completed_at;
// $this->is_recurring = $is_recurring;
// $this->recurrence_rule = $recurrence_rule;
// $this->reminder_at = $reminder_at;
// }
// }
function change_status($todo_id, $status)
{
exec_stmt('UPDATE todos SET status = ? WHERE todo_id = ?', 'si', $status, $todo_id);
}
function create_todo($todo)
{
exec_stmt('INSERT INTO todos (title, description, priority, due_date, category, status) VALUES (?,?,?,?,?,?)', 'ssssss', $todo['title'], $todo['description'], $todo['priority'], $todo['due_date'], $todo['category'], 'open');
}
function update_todo($todo_id, $todo)
{
exec_stmt('UPDATE todos SET title = ? WHERE todo_id = ?', 'si', $todo['title'], $todo_id);
exec_stmt('UPDATE todos SET description = ? WHERE todo_id = ?', 'si', $todo['description'], $todo_id);
exec_stmt('UPDATE todos SET priority = ? WHERE todo_id = ?', 'si', $todo['priority'], $todo_id);
exec_stmt('UPDATE todos SET due_date = ? WHERE todo_id = ?', 'si', $todo['due_date'], $todo_id);
exec_stmt('UPDATE todos SET category = ? WHERE todo_id = ?', 'si', $todo['category'], $todo_id);
exec_stmt('UPDATE todos SET status = ? WHERE todo_id = ?', 'si', $todo['status'], $todo_id);
}
+371
View File
@@ -0,0 +1,371 @@
<?php
class LoginCredentials
{
private string $username;
private string $password;
public function __construct(string $username, string $password)
{
// More intensive SQL injection checks (example - adapt as needed)
if (preg_match('/[\'";\-\_]/', $username) || preg_match('/[\'";\-\_]/', $password)) {
throw new InvalidArgumentException('Invalid characters in username or password.');
}
// Consider using a more robust validation library
$this->username = $username;
$this->password = $password;
}
public function getUsername(): string
{
return $this->username;
}
public function getPassword(): string
{
return $this->password;
}
}
class User
{
private int $userId;
private string $username;
private ?DateTime $createdAt;
private ?DateTime $lastActive;
private bool $isActive;
private ?string $profilePicture;
private ?string $bio;
private ?string $website;
public function __construct(int $userId, string $username, ?string $profilePicture = null, ?string $bio = null, ?string $website = null, ?mysqli $db = null)
{
// Basic XSS prevention on construction (can be enhanced)
$username = htmlspecialchars($username, ENT_QUOTES, 'UTF-8');
$bio = htmlspecialchars($bio ?? '', ENT_QUOTES, 'UTF-8');
$website = htmlspecialchars($website ?? '', ENT_QUOTES, 'UTF-8');
$profilePicture = htmlspecialchars($profilePicture ?? 'default-profile.png', ENT_QUOTES, 'UTF-8');
// Basic SQL injection prevention (should primarily rely on prepared statements)
if (preg_match('/[\'";\-\_]/', $username) || preg_match('/[\'";\-\_]/', $bio) || preg_match('/[\'";\-\_]/', $website) || preg_match('/[\'";\-\_]/', $profilePicture)) {
throw new InvalidArgumentException('Invalid characters in user data.');
}
$this->userId = $userId;
$this->username = $username;
$this->profilePicture = $profilePicture;
$this->bio = $bio;
$this->website = $website;
$this->createdAt = new DateTime();
$this->lastActive = null;
$this->isActive = false;
if ($db) {
$stmt = $db->prepare('INSERT INTO user (user_id, username, profile_picture, bio, website) VALUES (?, ?, ?, ?, ?)');
if ($stmt) {
$stmt->bind_param('issss', $this->userId, $this->username, $this->profilePicture, $this->bio, $this->website);
$stmt->execute();
$stmt->close();
} else {
error_log('Error preparing statement: ' . $db->error);
}
} else {
// Consider logging or throwing an exception if no database connection is provided
error_log('Database connection not provided during User creation.');
}
}
public static function exists(int $id)
{
$stmt = 'SELECT count(username) FROM user WHERE id = ?';
echo pretty_dump(exec_stmt($stmt, 'i', $id)->fetch_assoc());
}
public static function login(LoginCredentials $credentials, ?mysqli $db): ?User
{
if (!$db) {
error_log('Database connection not provided for login.');
return null;
}
$username = $credentials->getUsername();
$password = $credentials->getPassword();
$stmt = $db->prepare('SELECT user_id, username, password_hash, profile_picture, bio, website FROM user WHERE username = ?');
if ($stmt) {
$stmt->bind_param('s', $username);
$stmt->execute();
$result = $stmt->get_result();
$user = $result->fetch_assoc();
$stmt->close();
if ($user && hash('sha256', $password) === $user['password_hash']) {
return new User(
(int) $user['user_id'],
$user['username'],
$user['profile_picture'],
$user['bio'],
$user['website']
);
}
} else {
error_log('Error preparing statement: ' . $db->error);
}
return null;
}
public static function logout(): void
{
foreach (array_keys($_SESSION) as $key) {
unset($_SESSION[$key]);
}
// Consider destroying the session cookie as well: session_destroy();
header('Location: .'); // Redirect to the homepage or login page
exit();
}
public function getUserId(): int
{
return $this->userId;
}
public function getUsername(): string
{
return $this->username;
}
public function getCreatedAt(): ?DateTime
{
return $this->createdAt;
}
public function getLastActive(): ?DateTime
{
return $this->lastActive;
}
public function isActive(): bool
{
return $this->isActive;
}
public function getProfilePicture(): ?string
{
return $this->profilePicture;
}
public function getBio(): ?string
{
return $this->bio;
}
public function getWebsite(): ?string
{
return $this->website;
}
public function setLastActive(?DateTime $lastActive): void
{
$this->lastActive = $lastActive;
}
public function setIsActive(bool $isActive): void
{
$this->isActive = $isActive;
}
public function toggleActive(?mysqli $db): bool
{
if (!$db) {
error_log('Database connection not provided for toggleActive.');
return false;
}
$this->isActive = !$this->isActive;
$stmt = $db->prepare('UPDATE user SET is_active = ? WHERE user_id = ?');
if ($stmt) {
$stmt->bind_param('ii', (int) $this->isActive, $this->userId);
$result = $stmt->execute();
$stmt->close();
return $result;
} else {
error_log('Error preparing statement: ' . $db->error);
return false;
}
}
public function updateUsername(string $newUsername, ?mysqli $db): bool
{
$newUsername = htmlspecialchars($newUsername, ENT_QUOTES, 'UTF-8');
if (preg_match('/[\'";\-\_]/', $newUsername)) {
throw new InvalidArgumentException('Invalid characters in username.');
}
if (!$db) {
error_log('Database connection not provided for updateUsername.');
return false;
}
$stmt = $db->prepare('UPDATE user SET username = ? WHERE user_id = ?');
if ($stmt) {
$stmt->bind_param('si', $newUsername, $this->userId);
$result = $stmt->execute();
$stmt->close();
if ($result) {
$this->username = $newUsername;
return true;
}
} else {
error_log('Error preparing statement: ' . $db->error);
}
return false;
}
public function updatePassword(string $newPassword, ?mysqli $db): bool
{
$newPasswordHash = hash('sha256', $newPassword);
if (!$db) {
error_log('Database connection not provided for updatePassword.');
return false;
}
$stmt = $db->prepare('UPDATE user SET password_hash = ? WHERE user_id = ?');
if ($stmt) {
$stmt->bind_param('si', $newPasswordHash, $this->userId);
$result = $stmt->execute();
$stmt->close();
return $result;
} else {
error_log('Error preparing statement: ' . $db->error);
}
return false;
}
public function updateProfilePicture(?string $newProfilePicture, ?mysqli $db): bool
{
$newProfilePicture = htmlspecialchars($newProfilePicture ?? 'default-profile.png', ENT_QUOTES, 'UTF-8');
if (preg_match('/[\'";\-\_]/', $newProfilePicture)) {
throw new InvalidArgumentException('Invalid characters in profile picture filename.');
}
if (!$db) {
error_log('Database connection not provided for updateProfilePicture.');
return false;
}
$stmt = $db->prepare('UPDATE user SET profile_picture = ? WHERE user_id = ?');
if ($stmt) {
$stmt->bind_param('si', $newProfilePicture, $this->userId);
$result = $stmt->execute();
$stmt->close();
if ($result) {
$this->profilePicture = $newProfilePicture;
return true;
}
} else {
error_log('Error preparing statement: ' . $db->error);
}
return false;
}
public function followUser(int $followingUserId, bool $notify = false, ?mysqli $db): bool
{
if (!$db) {
error_log('Database connection not provided for followUser.');
return false;
}
$stmt = $db->prepare('INSERT INTO follows (follower_user_id, following_user_id, notify_user) VALUES (?, ?, ?)');
if ($stmt) {
$stmt->bind_param('iii', $this->userId, $followingUserId, (int) $notify);
$result = $stmt->execute();
$stmt->close();
return $result;
} else {
error_log('Error preparing statement: ' . $db->error);
return false;
}
}
public function getFollowers(?mysqli $db): array
{
if (!$db) {
error_log('Database connection not provided for getFollowers.');
return [];
}
$stmt = $db->prepare('SELECT u.user_id, u.username, u.profile_picture, u.bio, u.website FROM follows f JOIN user u ON f.follower_user_id = u.user_id WHERE f.following_user_id = ?');
if ($stmt) {
$stmt->bind_param('i', $this->userId);
$stmt->execute();
$result = $stmt->get_result();
$followers = [];
while ($row = $result->fetch_assoc()) {
$followers[] = new User(
(int) $row['user_id'],
$row['username'],
$row['profile_picture'],
$row['bio'],
$row['website']
);
}
$stmt->close();
return $followers;
} else {
error_log('Error preparing statement: ' . $db->error);
return [];
}
}
public function getFollowing(?mysqli $db): array
{
if (!$db) {
error_log('Database connection not provided for getFollowing.');
return [];
}
$stmt = $db->prepare('SELECT u.user_id, u.username, u.profile_picture, u.bio, u.website FROM follows f JOIN user u ON f.following_user_id = u.user_id WHERE f.follower_user_id = ?');
if ($stmt) {
$stmt->bind_param('i', $this->userId);
$stmt->execute();
$result = $stmt->get_result();
$following = [];
while ($row = $result->fetch_assoc()) {
$following[] = new User(
(int) $row['user_id'],
$row['username'],
$row['profile_picture'],
$row['bio'],
$row['website']
);
}
$stmt->close();
return $following;
} else {
error_log('Error preparing statement: ' . $db->error);
return [];
}
}
// Static method to fetch a User by ID (example using mysqli)
public static function findById(int $userId, ?mysqli $db): ?User
{
if (!$db) {
error_log('Database connection not provided for findById.');
return null;
}
$stmt = $db->prepare('SELECT user_id, username, profile_picture, bio, website FROM user WHERE user_id = ?');
if ($stmt) {
$stmt->bind_param('i', $userId);
$stmt->execute();
$result = $stmt->get_result();
$user = $result->fetch_assoc();
$stmt->close();
if ($user) {
return new User(
(int) $user['user_id'],
$user['username'],
$user['profile_picture'],
$user['bio'],
$user['website']
);
}
} else {
error_log('Error preparing statement: ' . $db->error);
}
return null;
}
}
class Author extends User {}
+5 -5
View File
@@ -1,15 +1,15 @@
<?php <?php
require_once 'functions/init.php'; require_once 'functions/init.php';
require_once 'functions/functions.php'; require_once 'functions/core.php';
include_once 'components/head.php'; include_once 'components/core/head.php';
echo home_content(); echo home_content();
include_once 'components/foot.php'; include_once 'components/core/foot.php';
function home_content() function home_content()
{ {
$content = ' $content = '
<div class="article"> <div class="article">
<h1>Vintage Coding</h1> <h1>Vintage Coding</h1>
<h3>Intentional Craftsmanship Over Ephemeral Trends</h3> <h3>Intentional Craftsmanship Over Ephemeral Trends</h3>
@@ -30,5 +30,5 @@ function home_content()
</div> </div>
'; ';
return $content; return $content;
} }
-14
View File
@@ -1,14 +0,0 @@
USE vintagecoding;
INSERT INTO user (user_id, username, email, password_hash, role_id, profile_picture)
VALUES (5749248, 'jsmith', 'jsmith@email.com', 'f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b', 4, '2025.jpg'),
(5789203, 'dummy', 'dummy@iamdumb.com', 'f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b', 4, '2025.jpg'),
(5723948, 'writer', 'writer@something.com', 'f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b', 3, '5723948.jpg')
;
INSERT INTO article (author_id, title, excerpt, published_at)
VALUES (2025, 'Testing vintagecoding.net', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas a mauris sit amet dui pellentesque sodales. Pellentesque non viverra leo, et congue metus.', CURRENT_TIMESTAMP),
(5723948, 'Something Else', 'Here goes some random crap. Lorem ipsum akdfljskjlag IDK what to put here. I am just filling the space requirement. Blah blah blah blah, what does this look like? Is vintagecoding.net cool?', CURRENT_TIMESTAMP)
;
INSERT INTO article_tags (article_id, tag_id) VALUES (21, 2), (21, 6);
-209
View File
@@ -1,209 +0,0 @@
CREATE
(owner:Role {name: 'Owner'}),
(developer:Role {name: 'Developer'}),
(user:Role {name: 'User'}),
(essay:essay_postType {name: 'Essay'}),
(moment:essay_postType {name: 'Moment'}),
(thought:essay_postType {name: 'Thought'}),
(web_dev:essay_postCategory {name: 'Web-dev'}),
(devlog:essay_postCategory {name: 'Devlog'}),
(published:essay_postStatus {name: 'Published'}),
(draft:essay_postStatus {name: 'Draft'}),
(open:SpaceType {name: 'Open'}),
(public:SpaceType {name: 'Public'}),
(shared:SpaceType {name: 'Shared'}),
(private:SpaceType {name: 'Private'}),
(open_space:Space
{
name: 'Open Space',
description: 'World Wide access, no account required.',
created_at: datetime()
})-
[:SPACE_TYPE]->
(open),
(public_space:Space
{
name: 'Public Space',
description: 'Available to everyone with an account.',
created_at: datetime()
})-
[:SPACE_TYPE]->
(public),
(shared_space:Space
{
name: 'Dummy Shared Space',
description: 'Dummy shared space. Weird.',
created_at: datetime()
})-
[:SPACE_TYPE]->
(shared),
(owner_space:Space
{
name: 'Owner Space',
description: 'My space. Wait. Is that what this is?',
created_at: datetime()
})-
[:SPACE_TYPE]->
(private),
(dummy_space:Space
{
name: 'Dummy Space',
description: 'Dummy space. Weird.',
created_at: datetime()
})-
[:SPACE_TYPE]->
(private),
(owner_user:User
{
username: 'joshashton',
email: 'me@joshashton.dev',
password_hash:
'057ba03d6c44104863dc7361fe4578965d1887360f90a0895882e58a6248fc86',
created_at: datetime(),
profile_picture: '2025.jpg'
})-
[:USER_TYPE]->
(owner),
(owner_user)-[:USER_TYPE]->(developer),
(owner_user)-[:USER_TYPE]->(user),
(owner_user)-[:SPACE_MEMBER]->(open_space),
(owner_user)-[:SPACE_MEMBER]->(owner_space),
(owner_user)-[:SPACE_MEMBER]->(shared_space),
(dummy_user:User
{
username: 'dummy',
email: 'joshuatashton@gmail.com',
password_hash:
'057ba03d6c44104863dc7361fe4578965d1887360f90a0895882e58a6248fc86',
created_at: datetime(),
profile_picture: '2025.jpg'
})-
[:USER_TYPE]->
(user),
(dummy_user)-[:FOLLOWS {start_at: datetime()}]->(owner_user),
(dummy_user)-[:SPACE_MEMBER]->(open_space),
(dummy_user)-[:SPACE_MEMBER]->(dummy_space),
(dummy_user)-[:SPACE_MEMBER]->(shared_space),
(essay_post:Post
{
title: 'Building vintagecoding.net',
view_count: 0,
excerpt:
'Follow the journey of the creation of vintagecoding.net, where I discuss roadblocks and solutions. Hello world!',
content:
"
#### Overview:
This site is built in PHP with MariaDB and a Golang HTTP server. Composer is being used to supply the following PHP packages:
- `vlucas/phpdotenv`
- `erusev/parsedown`
- `wildbit/postmark-php`
---
#### 19/03/2025 - Foundations:
Tonight, I [pushed](https://github.com/quaxlyqueen/vintagecoding.net/tree/5af0547864f730091c98d64cc18014bc69d0a041) the first major feature implementation. The database is designed, and a component has been created for article cards, and markdown parsing into HTML for the articles themselves.
---
#### 20/03/2025 - Refining UX:
Just now, I [pushed](https://github.com/quaxlyqueen/vintagecoding.net/tree/c0fc9e9d0053975837ae48fa1db93b5095d33146) the second major feature implementation. I've narrowed in on the database design with small tweaks, setup some dynamic page loading on articles.php, created a navigation bar, a dark/light mode button, and styled everything up. It's shaping up pretty well!
There's a lot more work to be done. The entire login system needs to be implemented, user homepages, account management, admin panels, the article composer...the list goes on.
---
#### 25/03/2025 - Account Management:
Just now, I [pushed](https://github.com/quaxlyqueen/vintagecoding.net/tree/9a45075010434a4dedd0c5d48a78ff6be3bcfddc) the third major feature implementation: account management! Not the most exciting thing ever, but it is important for the long term success of this project. With that completed, v0.0.9 is completed! Though I prematurely labeled the commit v0.1.0...But in any case there's a lot to go before release.
There is now a sign up and login user flow, a user page with basic settings, and authorization for accessing things like the article composer. Oh yeah, I got a basic version of that running in [this commit](https://github.com/quaxlyqueen/vintagecoding.net/tree/0a27cd1f5cd02735d06a64090a05937a5d6bcf3a) a few hours ago.
Two major tasks remain prior to v0.1.0. First, I still need to finish implementing the image upload on new sign ups (and for changing a profile picture), but that should be relatively minor. Second, I need to use author information to flesh out article pages.
I'm now setting my sights on v0.2.0, and the myriad of features I'm planning on implementing for that. It'll be the account and customization overhaul. Tools for the owner and administrator(s), enabling users to request their role to be changed to contributor or administrator, user home pages with their own custom CSS, the ability to theme the site however they please, and mobile support! I'm excited just thinking about it. It'll require a MAJOR overhaul of the CSS, but thanks to the minimalist aesthetic, the CSS file is only a few hundred disorganized lines.
I've put maybe 30 hours into this project so far. I'm not sure if that's good or bad, but not getting crazy with the CSS has saved a **ton** of time. And, I really like how it's looking. I'm really excited about this project and I am giddy to see how much I can get implemented by the release on 25/05/2025 -- two months! I'm starting an *unpaid* internship tomorrow, so I probably won't have the time to put 30 hours a week into this. For now, I'm just excited to see what the future holds.
---
#### 01/04/2025 - Basic Security & Administration:
Earlier today, I [pushed](https://github.com/quaxlyqueen/vintagecoding.net/tree/cec9091ee01839b133ecbbc5616463a759837dda) a major security fix along with getting CI/CD setup, and last night I [pushed](https://github.com/quaxlyqueen/vintagecoding.net/tree/b9bb620ae01134431ff1941e7577ba58eee84e42) a secure overhaul of the 'Remember Me' functionality. I didn't write about it then, but a few days ago I also [pushed](https://github.com/quaxlyqueen/vintagecoding.net/tree/ff7e799b7fd2822cf27e0afe47274de222b2b508) the image upload along with a image cropper. Still buggy as hell, but its a start. And a couple days ago I [committed](https://github.com/quaxlyqueen/vintagecoding.net/tree/e6735a9a206a207b6fab704785451a6309944ddd) admin functionality to manage users and articles. I would've thought I'd slow down, but I'm just trucking along.
Specifically, the 'Remember Me' functionality is now using a token system, a randomly generated 1024 bit string, associated with the user ID and hashed values of both the remote address and (if used) forwarded for headers. If these don't match what is stored in the database, this should mean that the client browser/device is not the original browser/device used to create the token.
For the other security fix, I converted all of the `mysqli` statements to prepared statements. This is to mitigate the risk of SQL Injection attacks. Particularly since this site is live (though I'm not promoting it so it is still 'unreleased'), it is important to ensure that despite the lack of sensitive information, would-be attackers are unable to access user information, gain admin access, or compromise the server in any way.
Onto the user home pages! Here's a general breakdown of what I have in mind. Every user has a homepage that they can customize the CSS for. Effectively, give the users a place to make their own. Eventually even change the layout and structure of their page. For now, the page is going to have some pretty simple information: a list of articles written by the user, the number of articles they have read, and the number their articles have been read. More is to come in the future, but that's a long way out.
---
#### 08/04/2025 - Image Cropping & Editing Articles:
Yesterday, I [pushed](https://github.com/quaxlyqueen/vintagecoding.net/tree/e5b3e048b8dc6731fa383778c26eba47186e5310) an update to the image cropper. In other words, it *actually* works now lol. I was driving home from class and I just realized that I needed to both offset the position from the window to the image, and scale the coordinates based on the size of the image. And just a moment ago, I [pushed](https://github.com/quaxlyqueen/vintagecoding.net/tree/e829e2e56695af5b77074f926c8d0c270e501f4b) editing articles. And here and there, I've worked on some basic Cross Site Scripting preventative measures, honeypots on signup to help prevent bots, and basic mobile responsiveness.
With some work being done with security now, I'm having second thoughts about allowing users to have custom CSS on their homepages. I'll punt that off till later.
All in all, things are going smoothly! I'm pretty proud of what I've built so far, and it has been quite educational. From prepared SQL statements, to XSS, form processing, and secure 'remember me' functionality. I'm not sure how much this site will be used by anyone else, and if that is the case I'll eventually just use this as a personal blog.
---
#### 12/04/2025 - Enhanced Markdown Editing Experience:
This was my first foray into the world of AJAX. I just [pushed](https://github.com/quaxlyqueen/vintagecoding.net/tree/fb35e9322647878386bcc3be572cd404b38fd91b) the implementation of a markdown preview while composing or editing articles. The possibilities are **very** exciting, I'm already thinking of other places that the AJAX would be useful for.
Next, I'm looking to introduce Vim motions to the site, for general navigation and also for article composition and editing. It'll be an interesting challenge I think, and I could use the JavaScript practice.
I haven't said this previously, but I am *loving* PHP. Such a joy to work with, and pair that with AJAX for a very interactive experience? Phenomenal DX. I've been looking at an interesting project, [NativePHP](https://nativephp.com), that I'd like to eventually use for another project.
"
})-
[:STATUS]->
(published),
(essay_post)-[:POST_TYPE]->(essay),
(essay_post)-[:POST_CATEGORY]->(web_dev),
(essay_post)-[:POST_CATEGORY]->(devlog),
(essay_post)-[:IN_SPACE]->(open_space),
(owner_user)-[:WRITTEN {created_at: datetime(), updated_at: datetime()}]->
(essay_post),
(moment_post:Post
{
media_filepath: 'moments/open.png',
text_content:
'A hub for everything. A social network and messaging hub. A documentation hub. A photos and video hub. A note taking and task management hub. A private hub. An AI hub. Welcome to the.hub.'
})-
[:STATUS]->
(published),
(moment_post)-[:POST_TYPE]->(moment),
(moment_post)-[:POST_CATEGORY]->(devlog),
(moment_post)-[:IN_SPACE]->(shared_space),
(moment_post_2:Post
{
media_filepath: 'moments/test.png',
text_content:
'Here is a photo from a hike I went on a while back. Nothing quite beats the look over a long trail.'
})-
[:STATUS]->
(published),
(moment_post_2)-[:POST_TYPE]->(moment),
(moment_post_2)-[:POST_CATEGORY]->(devlog),
(moment_post_2)-[:IN_SPACE]->(shared_space),
(owner_user)-[:WRITTEN {created_at: datetime(), updated_at: datetime()}]->
(moment_post_2),
(thought_post:Post
{
content:
'I wonder if this is really smart or really dumb. Guess we will see...'
})-
[:STATUS]->
(published),
(thought_post)-[:POST_TYPE]->(thought),
(thought_post)-[:POST_CATEGORY]->(devlog),
(thought_post)-[:IN_SPACE]->(owner_space),
(owner_user)-[:WRITTEN {created_at: datetime(), updated_at: datetime()}]->
(thought_post),
(thought_post_2:Post
{content: 'I wonder if it was a bad idea to share my idea with Izzi.'})-
[:STATUS]->
(draft),
(thought_post_2)-[:POST_TYPE]->(thought),
(thought_post_2)-[:POST_CATEGORY]->(devlog),
(thought_post_2)-[:IN_SPACE]->(owner_space),
(owner_user)-[:WRITTEN {created_at: datetime(), updated_at: datetime()}]->
(thought_post_2),
(thought_post_3:Post {content: 'Hello cruel world!'})-[:STATUS]->(published),
(thought_post_3)-[:POST_TYPE]->(thought),
(thought_post_3)-[:POST_CATEGORY]->(devlog),
(thought_post_3)-[:IN_SPACE]->(public_space),
(owner_user)-[:WRITTEN {created_at: datetime(), updated_at: datetime()}]->
(thought_post_3);
-130
View File
@@ -1,130 +0,0 @@
CREATE DATABASE vintagecoding;
USE vintagecoding;
CREATE TABLE roles (
role_id INT PRIMARY KEY AUTO_INCREMENT,
role VARCHAR(16) NOT NULL
);
INSERT INTO roles (role)
VALUES ('owner'), ('developer'), ('user');
CREATE TABLE user (
user_id INT PRIMARY KEY,
username VARCHAR(32) NOT NULL UNIQUE,
email VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(256) NOT NULL,
role_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_active TIMESTAMP DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT FALSE,
profile_picture VARCHAR(256),
bio VARCHAR(256),
website VARCHAR(64),
FOREIGN KEY (role_id) REFERENCES roles (role_id) ON DELETE CASCADE
);
INSERT INTO user (user_id, username, email, password_hash, role_id, profile_picture)
VALUES (1, 'quaxlyqueen', 'me@joshashton.dev', '057ba03d6c44104863dc7361fe4578965d1887360f90a0895882e58a6248fc86', 1, '2025.jpg');
CREATE TABLE remember_user (
token VARCHAR(128) PRIMARY KEY,
user_id INT NOT NULL,
remote_addr VARCHAR(64) NOT NULL,
http_forward VARCHAR(64),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user (user_id) ON DELETE CASCADE
); -- TODO: Need cron job to auto-delete after 30 days of inactivity
CREATE TABLE post_types (
post_type_id INT PRIMARY KEY AUTO_INCREMENT,
post_type VARCHAR(16)
);
INSERT INTO post_types (post_type)
VALUES ('article'), ('moment'), ('thought');
CREATE TABLE post (
post_id INT PRIMARY KEY,
author_id INT NOT NULL,
post_type INT NOT NULL,
title VARCHAR(26) NOT NULL,
slug VARCHAR(32),
read_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
published_at TIMESTAMP DEFAULT NULL,
published BOOLEAN DEFAULT TRUE,
excerpt VARCHAR(256),
FOREIGN KEY (author_id) REFERENCES user(user_id) ON DELETE CASCADE,
FOREIGN KEY (post_type) REFERENCES post_types(post_type_id)
);
INSERT INTO post (author_id, post_type, title, excerpt, published_at)
VALUES (1, 1, 'Building vintagecoding.net', 'A devlog of the creation of vintagecoding.net and a test at the formatting of post cards and posts. Let the great experiment begin!', CURRENT_TIMESTAMP);
CREATE TABLE categories (
category_id INT PRIMARY KEY AUTO_INCREMENT,
category VARCHAR(16) NOT NULL
);
INSERT INTO categories (category)
VALUES ('web-dev'), ('devlog');
CREATE TABLE post_categories (
post_id INT NOT NULL,
category_id INT NOT NULL,
PRIMARY KEY (post_id, category_id),
FOREIGN KEY (post_id) REFERENCES post(post_id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(category_id) ON DELETE CASCADE
);
INSERT INTO post_tags (post_id, tag_id) VALUES (1, 1), (1, 2);
CREATE TABLE follows (
follower_user_id INT NOT NULL,
following_user_id INT NOT NULL,
notify_user BOOLEAN DEFAULT FALSE,
followed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (follower_user_id, following_user_id),
FOREIGN KEY (follower_user_id) REFERENCES user(user_id) ON DELETE CASCADE,
FOREIGN KEY (following_user_id) REFERENCES user(user_id) ON DELETE CASCADE
);
CREATE TABLE space_types (
space_type_id INT PRIMARY KEY AUTO_INCREMENT,
space_type VARCHAR(16)
);
INSERT INTO space_types (space_type)
VALUES ('open', 'public', 'shared', 'private');
CREATE TABLE spaces (
space_id INT PRIMARY KEY AUTO_INCREMENT,
space_type INT NOT NULL,
name VARCHAR(64) NOT NULL,
slug VARCHAR(64) UNIQUE,
description TEXT,
parent_space_id INT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (parent_space_id) REFERENCES spaces(space_id) ON DELETE SET NULL,
FOREIGN KEY (space_type) REFERENCES space_types(space_type_id)
);
CREATE TABLE space_posts (
post_id INT NOT NULL,
space_id INT NOT NULL,
PRIMARY KEY (post_id, space_id),
FOREIGN KEY (post_id) REFERENCES post(post_id) ON DELETE CASCADE,
FOREIGN KEY (space_id) REFERENCES spaces(space_id) ON DELETE CASCADE
);
CREATE TABLE space_members (
space_id INT NOT NULL,
user_id INT NOT NULL,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (space_id, user_id),
FOREIGN KEY (space_id) REFERENCES spaces(space_id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user(user_id) ON DELETE CASCADE
);
-54
View File
@@ -1,54 +0,0 @@
ARGS_COUNT=$#
help () {
echo "CLI Tool used to initialize the database for vintagecoding.net."
echo "With no arguments, it creates the application user in MariaDB,"
echo "and creates the following tables:"
echo "- roles (populated);"
echo "- tags (populated);"
echo "- user;"
echo "- articles;"
echo "- article_tags;"
echo ""
echo "The following flags are accepted:"
echo " init_db.sh { --help | -h }"
echo " init_db.sh { --delete | -d } { --verbose | -v }"
}
if [[ $ARGS_COUNT > 0 ]]; then
if [[ "$1" == "--help" || "$1" == "-h" ]]; then
help
exit
fi
# Delete the existing database and start over.
if [[ "$1" == "--delete" || "$1" == "-d" ]]; then
if [[ "$2" == "--verbose" || "$2" == "-v" ]]; then
echo "Deleting the existing database vintagecoding..."
fi
echo "vintage user pw"
mariadb -u vintage -p -e "DROP DATABASE vintagecoding;"
if [[ "$2" == "--verbose" || "$2" == "-v" ]]; then
echo "Deleting the vintage user..."
fi
echo "root user pw"
mariadb -u root -p -e "DROP USER vintage@localhost;"
if [[ "$2" == "--verbose" || "$2" == "-v" ]]; then
echo "Creating the vintage user..."
fi
echo "root user pw"
mariadb -u root -p < setup.sql;
if [[ "$2" == "--verbose" || "$2" == "-v" ]]; then
echo "Creating (and populating enum/static data) tables..."
fi
echo "vintage user pw"
mariadb -u vintage -p < init.sql;
exit
fi
fi
#mariadb -u root -p < setup.sql;
mariadb -u vintage -p < init.sql;
-3
View File
@@ -1,3 +0,0 @@
--This must be ran as the root user in MariaDB
CREATE USER vintage@localhost IDENTIFIED BY 'changeme';
GRANT ALL PRIVILEGES ON vintagecoding.* TO vintage@localhost IDENTIFIED BY 'changeme';
+47
View File
@@ -0,0 +1,47 @@
<?php
require_once 'functions/init.php';
require_once 'functions/core.php';
require_once 'functions/space.php';
// UI Components
foreach (glob('components/core/*.php') as $file)
require_once $file;
foreach (glob('components/spaces/*.php') as $file)
require_once $file;
include_once 'components/core/head.php';
/* if (!isset($_SESSION['role_id'])) { */
/* $redirect = 'todo.php?view=display'; */
/* header('Location: user.php?view=login&redirect=' . urlencode($redirect)); */
/* } */
// Display user's spaces
if (isset($_SESSION['user'])) {
switch ($_GET['view']) {
case 'displaySpaces':
echo displaySpace();
break;
case 'displaySpace':
break;
case 'displayFeed':
/* if (isset($_GET['space_id']) || isset($_GET['user_id'])) */
break;
case 'displaySpacePosts':
break;
}
} else {
// Treat as displaySpacePosts
echo displaySpace();
}
include_once 'components/core/foot.php';
function displaySpace($spaceId = 2025)
{
$space = Space::retrieveFromDB($spaceId, null);
$html = '
<p>' . $space->getId() . '</p>
';
return $html;
}
-29
View File
@@ -1,29 +0,0 @@
<?php
require_once 'functions/init.php';
require_once 'functions/functions.php';
require_once 'functions/todo_functions.php';
// UI Components
foreach (glob('components/todo/*.php') as $file)
require_once $file;
include_once 'components/head.php';
if (!isset($_SESSION['role_id'])) {
$redirect = 'todo.php?view=display';
header('Location: user.php?view=login&redirect=' . urlencode($redirect));
}
if (isset($_GET['view']))
$view = $_GET['view'];
else
$view = 'display';
switch ($view) {
case 'display':
$todos = exec_stmt('SELECT * FROM todos WHERE user_id = ? ORDER BY updated_at DESC', 'i', $_SESSION['user_id']);
echo todo_display($todos);
break;
}
include_once 'components/foot.php';
+8 -8
View File
@@ -1,7 +1,7 @@
<?php <?php
require_once 'functions/init.php'; require_once 'functions/init.php';
require_once 'functions/functions.php'; require_once 'functions/core.php';
include_once 'functions/account_functions.php'; include_once 'functions/user.php';
// UI Components // UI Components
foreach (glob('components/user/*.php') as $file) foreach (glob('components/user/*.php') as $file)
@@ -45,7 +45,7 @@ if (isset($_POST['form_id'])) {
break; break;
case 'reset_pw_form': case 'reset_pw_form':
$msg = reset_password( $msg = reset_password(
($_SESSION['role_id'] <= admin && $_SESSION['user_id'] != $_POST['user_id'] ? $_POST['user_id'] : $_SESSION['user_id']), ($_SESSION['role_id'] <= developer && $_SESSION['user_id'] != $_POST['user_id'] ? $_POST['user_id'] : $_SESSION['user_id']),
$_POST['new_pw'], $_POST['new_pw'],
$_POST['verify_new_pw'] $_POST['verify_new_pw']
); );
@@ -53,7 +53,7 @@ if (isset($_POST['form_id'])) {
break; break;
case 'update_email_form': case 'update_email_form':
$msg = update_email( $msg = update_email(
($_SESSION['role_id'] <= admin && $_SESSION['user_id'] != $_POST['user_id'] ? $_POST['user_id'] : $_SESSION['user_id']), ($_SESSION['role_id'] <= developer && $_SESSION['user_id'] != $_POST['user_id'] ? $_POST['user_id'] : $_SESSION['user_id']),
$_POST['new_email'], $_POST['new_email'],
$_POST['verify_new_email'] $_POST['verify_new_email']
); );
@@ -61,7 +61,7 @@ if (isset($_POST['form_id'])) {
break; break;
case 'update_username_form': case 'update_username_form':
$msg = update_username( $msg = update_username(
($_SESSION['role_id'] <= admin && $_SESSION['user_id'] != $_POST['user_id'] ? $_POST['user_id'] : $_SESSION['user_id']), ($_SESSION['role_id'] <= developer && $_SESSION['user_id'] != $_POST['user_id'] ? $_POST['user_id'] : $_SESSION['user_id']),
$_POST['new_username'], $_POST['new_username'],
$_POST['verify_new_username'] $_POST['verify_new_username']
); );
@@ -77,7 +77,7 @@ if (isset($_POST['form_id'])) {
break; break;
case 'delete_account_form': case 'delete_account_form':
$msg = delete_account( $msg = delete_account(
($_SESSION['role_id'] <= admin && $_SESSION['user_id'] != $_POST['user_id'] ? $_POST['user_id'] : $_SESSION['user_id']), ($_SESSION['role_id'] <= developer && $_SESSION['user_id'] != $_POST['user_id'] ? $_POST['user_id'] : $_SESSION['user_id']),
$_POST['delete_password'], $_POST['delete_password'],
$_POST['delete_verify_password'] $_POST['delete_verify_password']
); );
@@ -93,7 +93,7 @@ if (isset($_POST['form_id'])) {
echo $msg; echo $msg;
} }
include_once 'components/head.php'; include_once 'components/core/head.php';
switch ($_GET['view']) { switch ($_GET['view']) {
case 'login': case 'login':
@@ -110,4 +110,4 @@ switch ($_GET['view']) {
break; break;
} }
include_once 'components/foot.php'; include_once 'components/core/foot.php';