diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..1ec8739 --- /dev/null +++ b/.htaccess @@ -0,0 +1,6 @@ + +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.*)$ /index.php/$1 [L] + diff --git a/Application/Controllers/AuthController.php b/Application/Controllers/AuthController.php new file mode 100644 index 0000000..2394f41 --- /dev/null +++ b/Application/Controllers/AuthController.php @@ -0,0 +1,133 @@ +isLoggedIn()) { + return $response->redirect('/'); + } + return $response->view('sign-up'); + } + public function create_user(Request $request, Response $response) { + if($request->email == "") { + return $response->redirect("/signup")->with( + [ 'errors' => 'Email must not be empty' ] + ); + } else if (!filter_var($request->email,FILTER_VALIDATE_EMAIL)) { + return $response->redirect("/signup")->with( + [ 'errors' => 'Email must not valid' ] + ); + } else if ($request->username == "") { + return $response->redirect("/signup")->with( + [ 'errors' => 'Username must not be empty' ] + ); + } else if (strlen($request->username) < 6 || strlen($request->username) > 20 ) { + return $response->redirect("/signup")->with( + [ 'errors' => 'Username must be between 6 and 20 characters' ] + ); + } else if (strlen($request->password) < 8) { + return $response->redirect("/signup")->with( + [ 'errors' => 'Password must be at least 8 characters' ] + ); + } else if ($request->password != $request->confirmpassword) { + return $response->redirect("/signup")->with( + [ 'errors' => 'Password and confirm password must be the same' ] + ); + } + ServiceContainer::Session()->unset('errors'); + $data = User::create([ + 'id' => null, + 'username' => $request->username, + 'about' => '', + 'email' => $request->email, + 'email_visible' => 0, + 'avatar_path' => '', + 'password' => password_hash($request->password,PASSWORD_DEFAULT), + 'is_confirmed' => 0, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + if($data != null) { + $id = $data->id; + $confirmator = UserConfirmation::create([ + 'confirm_key' => hash('sha256',$data->username.time()), + 'user_id' => $id, + 'best_before' => date('Y-m-d H:i:s', strtotime('+6 hours', time())), + ]); + $email = new MailBuilder(); + $body = "Thank you for registering with metaforums.\n"; + $body .= "To be able to explore the vast forum, use the URL below:\n\n"; + $body .= $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['SERVER_NAME'].'/signup/confirm?'.$confirmator->confirm_key; + $email->from("metaforums@nanao.moe")->to($data->email)->subject("Complete your registration on Metaforums")->body($body); + ServiceContainer::Email()->send($email); + if($confirmator != null) { + return $response->redirect('/signup/success')->with( + [ 'signup-email' => $request->email ], + ); + } + } + } + public function sign_up_success(Request $request, Response $response) { + return $response->view('sign-up-success'); + } + public function sign_up_confirm(Request $request, Response $response) { + $confirm = UserConfirmation::find($request->queryString()); + if(isset($confirm)) { + $id = $confirm->user_id; + $user = User::find($id); + $user->update([ 'is_confirmed' => 1 ]); + $confirm->delete(); + } + return $response->redirect('/'); + } + public function login(Request $request, Response $response) { + if(ServiceContainer::Authentication()->isLoggedIn()) { + return $response->redirect('/'); + } + return $response->view('login'); + } + public function login_check(Request $request, Response $response) { + if ($request->username == "") { + return $response->redirect("/login")->with( + [ 'errors' => 'Username must not be empty' ] + ); + } else if ($request->password == "") { + return $response->redirect("/login")->with( + [ 'errors' => 'Password must not be empty' ] + ); + } + $query = new QueryBuilder(); + $query = $query->select('id,password')->from('user')->where('username',$request->username)->where('is_confirmed',1)->build(); + $result = ServiceContainer::Database()->select($query); + if(count($result) == 0) { + return $response->redirect("/login")->with( + [ 'errors' => 'Wrong username or password' ] + ); + } else { + $password = $result[0]["password"]; + $verify = password_verify($request->password,$password); + if(!$verify) { + return $response->redirect("/login")->with( + [ 'errors' => 'Wrong username or password' ] + ); + } + } + ServiceContainer::Session()->set('user_id',$result[0]['id']); + return $response->redirect('/'); + } + public function logout(Request $request, Response $response) { + ServiceContainer::Session()->destroy(); + return $response->redirect('/login'); + } +} diff --git a/Application/Controllers/IndexController.php b/Application/Controllers/IndexController.php new file mode 100644 index 0000000..a96d863 --- /dev/null +++ b/Application/Controllers/IndexController.php @@ -0,0 +1,15 @@ +view('index'); + } +} diff --git a/Application/Foundations/MailBuilder.php b/Application/Foundations/MailBuilder.php new file mode 100644 index 0000000..7cf285a --- /dev/null +++ b/Application/Foundations/MailBuilder.php @@ -0,0 +1,26 @@ +to = $to; + return $this; + } + public function subject($subject) { + $this->subject = $subject; + return $this; + } + public function body($body) { + $this->message = $body; + return $this; + } + public function from($from) { + $this->headers .= "From: ".$from."\r\n"; + return $this; + } +} diff --git a/Application/Foundations/Model.php b/Application/Foundations/Model.php new file mode 100644 index 0000000..155677b --- /dev/null +++ b/Application/Foundations/Model.php @@ -0,0 +1,71 @@ +attributes = $data; + } + public static function create($data) { + $calling_class = get_called_class(); + $class = explode('\\',get_called_class()); + $tablename = strtolower($class[count($class)-1]); + $result = ServiceContainer::Database()->insert($tablename, $data); + if($result) { + $data['id'] = $result; + } + $inst = new $calling_class(); + $inst->hydrate($result); + return $inst; + } + public static function find($key) { + $calling_class = get_called_class(); + $inst = new $calling_class(); + $class = explode('\\',get_called_class()); + $tablename = strtolower($class[count($class)-1]); + $query = new QueryBuilder(); + $query = $query->select('*')->from($tablename)->where($inst->primary_key,$key)->build(); + $result = ServiceContainer::Database()->select($query); + if(count($result) == 0) return null; + $inst->hydrate($result[0]); + return $inst; + } + public function update($key) { + $calling_class = get_called_class(); + $class = explode('\\',get_called_class()); + $tablename = strtolower($class[count($class)-1]); + $query = new QueryBuilder(); + $query = $query->update($tablename)->set($key)->where($this->primary_key,$this->attributes[$this->primary_key])->build(); + $result = ServiceContainer::Database()->update($query); + if(!$result) return null; + else { + return $this; + } + } + public function delete() { + $calling_class = get_called_class(); + $class = explode('\\',get_called_class()); + $tablename = strtolower($class[count($class)-1]); + $query = new QueryBuilder(); + $query = $query->delete()->from($tablename)->where($this->primary_key,$this->attributes[$this->primary_key])->build(); + $result = ServiceContainer::Database()->update($query); + if(!$result) return $this; + else { + return null; + } + } + function __get($prop) { + return $this->attributes[$prop]; + } + + function __set($prop, $val) { + $this->attributes[$prop] = $val; + } + +} diff --git a/Application/Foundations/QueryBuilder.php b/Application/Foundations/QueryBuilder.php new file mode 100644 index 0000000..3ac5add --- /dev/null +++ b/Application/Foundations/QueryBuilder.php @@ -0,0 +1,82 @@ +query .= "SELECT ".$fields; + } else { + $this->query .= "SELECT ".implode(",",SQLHelper::encode_list($fields)); + } + return $this; + } + public function delete() { + $this->query .= "DELETE"; + return $this; + } + public function update($table) { + // TODO: SQL injection + $this->query .= "UPDATE ".$table; + return $this; + } + public function set($data) { + $this->query .= " SET"; + $final = []; + foreach($data as $key => $value) { + $final[] = $key." = ".SQLHelper::encode_literal($value); + } + $this->query .= " ".implode(",",$final); + return $this; + } + public function from($table) { + // TODO: SQL injection + $this->query .= " FROM ".$table; + return $this; + } + public function where($a, $b, $c = null) { + $field = ""; + $value = ""; + $operator = "="; + if($c == null) { + // 2 param syntax + $field = $a; + $value = $b; + } else { + $field = $a; + $value = $c; + $operator = $b; + } + $value = SQLHelper::encode_literal($value); + if($this->where == "") { + $this->where .= " WHERE ".$field." ".$operator." ".$value; + } else { + $this->where .= " AND ".$field." ".$operator." ".$value; + } + return $this; + } + public function orWhere($a, $b, $c = null) { + $field = ""; + $value = ""; + $operator = "="; + if($c == null) { + // 2 param syntax + $field = $a; + $value = $b; + } else { + $field = $a; + $value = $c; + $operator = $b; + } + if($this->where == "") { + $this->where .= " WHERE ".$field." ".$operator." ".$value; + } else { + $this->where .= " OR ".$field." ".$operator." ".$value; + } + return $this; + } + public function build() { + return $this->query.$this->where; + } +} diff --git a/Application/Foundations/SQLHelper.php b/Application/Foundations/SQLHelper.php new file mode 100644 index 0000000..e4dea08 --- /dev/null +++ b/Application/Foundations/SQLHelper.php @@ -0,0 +1,29 @@ + $val) { + $insert_data[$index] = SQLHelper::encode_literal($val); + } + return $insert_data; + } + public static function encode_literal($val) { + $db = ServiceContainer::Database(); + if(is_numeric($val)) { + return $val; + } else if(is_null($val)) { + return 'NULL'; + } else if(!is_numeric($val)) { + return '"'.$db->escapeString($val).'"'; + } else if($val == "") { + return '""'; + } else { + return $val; + } + } +} diff --git a/Application/HTTP/Request.php b/Application/HTTP/Request.php new file mode 100644 index 0000000..194fa21 --- /dev/null +++ b/Application/HTTP/Request.php @@ -0,0 +1,20 @@ +data = $_REQUEST; + $this->query = $_SERVER['QUERY_STRING']; + } + function queryString() { + return $this->query; + } + function __get($prop) { + return $this->data[$prop]; + } + function __set($prop, $val) { + $this->data[$prop] = $val; + } +} diff --git a/Application/HTTP/Response.php b/Application/HTTP/Response.php new file mode 100644 index 0000000..af83924 --- /dev/null +++ b/Application/HTTP/Response.php @@ -0,0 +1,48 @@ +body .= ServiceContainer::View()->render($path,$args); + return $this; + } + public function data($data) { + return $this->json()->body(json_encode($data)); + } + public function body($body) { + $this->body = $body; + return $this; + } + public function statusCode($status) { + $this->status = $status; + return $this; + } + public function header($head) { + $this->headers[] = $head; + return $this; + } + public function json() { + return $this->header('Content-Type: application/json'); + } + public function render() { + http_response_code($this->status); + foreach($this->headers as $header) { + header($header); + } + echo $this->body; + } + public function redirect($path) { + return $this->header('Location: '.$path); + } + public function with($data) { + foreach($data as $key => $val) { + ServiceContainer::Session()->set($key,$val); + } + return $this; + } +} diff --git a/Application/Models/User.php b/Application/Models/User.php new file mode 100644 index 0000000..4d61e2f --- /dev/null +++ b/Application/Models/User.php @@ -0,0 +1,8 @@ +has('user_id'); + } +} diff --git a/backend/Mitsumine/Services/Config.php b/Application/Services/Config.php similarity index 67% rename from backend/Mitsumine/Services/Config.php rename to Application/Services/Config.php index 675b739..1269313 100644 --- a/backend/Mitsumine/Services/Config.php +++ b/Application/Services/Config.php @@ -1,10 +1,10 @@ configs = require 'backend/config.php'; + $this->configs = require 'config.php'; } public function __call($name, $args) { return $this->configs[$name]; diff --git a/Application/Services/Database.php b/Application/Services/Database.php new file mode 100644 index 0000000..544e294 --- /dev/null +++ b/Application/Services/Database.php @@ -0,0 +1,43 @@ +config = ServiceContainer::Config(); + $this->conn = mysqli_connect($this->config->db_host(),$this->config->db_user(),$this->config->db_pass(),$this->config->db_name()); + } + public function insert($table, $data) { + $insert_data = SQLHelper::encode_list($data); + $key_names = array_keys($insert_data); + $query = "INSERT INTO ".$table." (".implode(",",$key_names).") VALUES (".implode(",",$insert_data).")"; + $result = mysqli_query($this->conn,$query); + if($result) { + return mysqli_insert_id($this->conn); + } else { + echo mysqli_error($this->conn); + return null; + } + } + public function update($query) { + $result = mysqli_query($this->conn,$query); + return $result; + } + public function select($query) { + $result = mysqli_query($this->conn,$query); + if($result) { + return mysqli_fetch_all($result,MYSQLI_ASSOC); + } else { + return null; + } + } + + // Escaping strings requires DB connection, which is only handled by the Database service. + public function escapeString($str) { + return mysqli_real_escape_string($this->conn,$str); + } +} diff --git a/Application/Services/Email.php b/Application/Services/Email.php new file mode 100644 index 0000000..5b79ef6 --- /dev/null +++ b/Application/Services/Email.php @@ -0,0 +1,10 @@ +to,$email->subject,$email->message,$email->headers); + } +} diff --git a/backend/Mitsumine/Services/ServiceContainer.php b/Application/Services/ServiceContainer.php similarity index 85% rename from backend/Mitsumine/Services/ServiceContainer.php rename to Application/Services/ServiceContainer.php index f43e917..765c680 100644 --- a/backend/Mitsumine/Services/ServiceContainer.php +++ b/Application/Services/ServiceContainer.php @@ -1,5 +1,5 @@ + + + + diff --git a/Application/Views/layouts/foot.php b/Application/Views/layouts/foot.php new file mode 100644 index 0000000..fb3af42 --- /dev/null +++ b/Application/Views/layouts/foot.php @@ -0,0 +1,5 @@ + + + + diff --git a/Application/Views/layouts/head.php b/Application/Views/layouts/head.php new file mode 100644 index 0000000..273ee32 --- /dev/null +++ b/Application/Views/layouts/head.php @@ -0,0 +1,23 @@ + + + + + Metaforums + + +
+
+ Metaforums +
+
+
+
+ isLoggedIn()) { ?> + Logout + + Login + Signup + +
+
+
diff --git a/Application/Views/login.php b/Application/Views/login.php new file mode 100644 index 0000000..1ca57b2 --- /dev/null +++ b/Application/Views/login.php @@ -0,0 +1,52 @@ + +
+
+

Login

+
+ +
+
+ +
+
+

{{ errors }}

+
+
+ +
+
+
+ + diff --git a/Application/Views/sign-up-success.php b/Application/Views/sign-up-success.php new file mode 100644 index 0000000..382e02c --- /dev/null +++ b/Application/Views/sign-up-success.php @@ -0,0 +1,9 @@ + +

You are registered!

+

However, you will need to confirm your email address before you can start exploring the wonderful world of our forum

+

Please check your inbox for further instructions

+ diff --git a/Application/Views/sign-up.php b/Application/Views/sign-up.php new file mode 100644 index 0000000..793f77c --- /dev/null +++ b/Application/Views/sign-up.php @@ -0,0 +1,76 @@ + +
+
+

Sign up

+
+ +
+
+ +
+
+ +
+
+ +
+
+

{{ errors }}

+
+
+ +
+
+
+ + diff --git a/README.md b/README.md index 77d3add..2afc24b 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,57 @@ # Metaforums An online web-based discussion forum application + +## How to set up + +1. Make sure mod_rewrite is enabled on Apache. +2. Create a MySQL / MariaDB database, and import Metaforums' schema (`schema.sql`) into the database. +3. Modify backend/config.php and adjust the configuration as needed. +4. Point the browser to `localhost` + ## Project Structure -This project is separated between the frontend and the backend application. -- backend/ - The backend of this application is written in PHP, and is API focused. - - index.php - The main handler of backend functions. Handles routing, loading of Mitsumine services, and conversion of array responses to JSON. - - Mitsumine/ - Mitsumine is a set of custom-written helper classes to consolidate frequently used code. - - Mitsumine/HTTP - Mitsumine HTTP contains a number of abstractions for HTTP, such as Request class - - Mitsumine/Services - Mitsumine Services contains a number of service classes for common functionality such as Database and Session. - - Application/ - Application contains classes that are the core of the application itself - - Controllers/ - Controllers contain controllers that return HTTP responses -- frontend/ - The frontend of this application, written in HTML and utilizes T + - index.php - This index file allows serving both frontend and backend from one endpoint. - + The main handler of backend functions. Handles routing, loading of Application services, and response handling. +- Application/ + Application contains classes that are the core of the application itself + - Assets/ + Assets contains buildable assets, for example source CSS. + - Controllers/ + Controllers contain controllers that return HTTP responses + - Foundations/ + Foundations are helper classes for various functions, such as an SQL query builder, and base model implementation; + - SQLHelper + Contains SQL escaping facilities. + - QueryBuilder + The SQL query builder. + - Model + The base model implementation. Contains common code for all models. + - HTTP + HTTP contains a number of abstractions for HTTP, such as Request class. + - Models/ + Models contain database models. + - Services/ + Services contains a number of service classes for common functionality such as Database and Session. + - Authentication + Provide auth related services. + - Config + Loads configuration and provides a facility to access the contents. + - Database + A service that centralizes database access. + - Email + A service for sending email. + - ServiceContainer + A service container that ensures every service is loaded once. + - Session + A service that contains centralized session management. + - View + Provides view rendering facilities + - Static/ + Static contains static files served directly by the application. For example, this contains built CSS. + - Views/ + Views contains all views used by the application + ## Software Stack The software is tested on the Apache server and PHP 7.3 on Arch Linux. @@ -32,14 +62,6 @@ The database used is MariaDB 10.4.8 ## External frontend libraries used -### Vue.js - -Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web - -Vue.js allows for interactivity while being less cumbersome than manipulating the DOM manually e.g. with jQuery. - -[Project Website](https://vuejs.org) - ### jQuery jQuery is a feature-rich JavaScript library. diff --git a/backend/autoload.php b/autoload.php similarity index 100% rename from backend/autoload.php rename to autoload.php diff --git a/backend/Application/Controllers/IndexController.php b/backend/Application/Controllers/IndexController.php deleted file mode 100644 index 9c1e072..0000000 --- a/backend/Application/Controllers/IndexController.php +++ /dev/null @@ -1,12 +0,0 @@ - 'yuika' - ]; - } -} diff --git a/backend/Mitsumine/HTTP/Request.php b/backend/Mitsumine/HTTP/Request.php deleted file mode 100644 index 1f59ff3..0000000 --- a/backend/Mitsumine/HTTP/Request.php +++ /dev/null @@ -1,6 +0,0 @@ -conn = mysqli_connect($config->db_host(),$config->db_user(),$config->db_pass(),$config->db_name()); - } -} diff --git a/backend/index.php b/backend/index.php deleted file mode 100644 index 98f67df..0000000 --- a/backend/index.php +++ /dev/null @@ -1,48 +0,0 @@ -$method($request); - -// Convert array to JSON -if(is_array($result)) { - header('Content-Type: application/json'); - $result = json_encode($result); -} -echo $result; - diff --git a/backend/routes.php b/backend/routes.php deleted file mode 100644 index c3ae232..0000000 --- a/backend/routes.php +++ /dev/null @@ -1,6 +0,0 @@ - [ - 'controller' => 'IndexController@index', - ], -]; diff --git a/backend/config.php b/config.php similarity index 100% rename from backend/config.php rename to config.php diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 6c39777..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - -
-{{ message }} -
- - - diff --git a/index.php b/index.php index 559d73e..6f05535 100644 --- a/index.php +++ b/index.php @@ -1,18 +1,65 @@ false, 'error' => 'Not found' ])); +} + +$route = $routes[$request_method.':'.$uri]; + +// Duar (actually, split the method string to class name and method name) +$method_part = explode("@",$route['controller']); + +// Get class name and method name +$class = $method_part[0]; +$method = $method_part[1]; + +// Get fully qualified class name of route +$fqcn = 'Application\\Controllers\\'.$class; +$controller = new $fqcn(); + +$response = $controller->$method($request,$response); + +$response->render(); + +// Convert array to JSON + diff --git a/routes.php b/routes.php new file mode 100644 index 0000000..6732a8a --- /dev/null +++ b/routes.php @@ -0,0 +1,27 @@ + [ + 'controller' => 'IndexController@index', + ], + 'GET:/signup' => [ + 'controller' => 'AuthController@sign_up', + ], + 'POST:/signup' => [ + 'controller' => 'AuthController@create_user', + ], + 'GET:/signup/success' => [ + 'controller' => 'AuthController@sign_up_success', + ], + 'GET:/signup/confirm' => [ + 'controller' => 'AuthController@sign_up_confirm', + ], + 'GET:/login' => [ + 'controller' => 'AuthController@login', + ], + 'POST:/login' => [ + 'controller' => 'AuthController@login_check', + ], + 'GET:/logout' => [ + 'controller' => 'AuthController@logout', + ], +];