Massively clean up JS and PHP

This commit is contained in:
Florine W. Dekker 2022-08-19 19:07:39 +02:00
parent 29de649fda
commit e6def72c2e
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
10 changed files with 206 additions and 101 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "fwdekker/death-notifier", "name": "fwdekker/death-notifier",
"description": "Get notified when a famous person dies.", "description": "Get notified when a famous person dies.",
"version": "0.0.13", "version": "0.0.14",
"type": "project", "type": "project",
"license": "MIT", "license": "MIT",
"homepage": "https://git.fwdekker.com/tools/death-notifier", "homepage": "https://git.fwdekker.com/tools/death-notifier",

BIN
composer.lock generated

Binary file not shown.

BIN
package-lock.json generated

Binary file not shown.

View File

@ -1,6 +1,6 @@
{ {
"name": "death-notifier", "name": "death-notifier",
"version": "0.0.13", "version": "0.0.14",
"description": "Get notified when a famous person dies.", "description": "Get notified when a famous person dies.",
"author": "Florine W. Dekker", "author": "Florine W. Dekker",
"browser": "dist/bundle.js", "browser": "dist/bundle.js",

View File

@ -145,11 +145,10 @@ if (isset($_POST["action"])) {
case "login": case "login":
$response = validate_csrf() $response = validate_csrf()
?? validate_logged_out() ?? validate_logged_out()
?? validate_has_arguments($_POST["email"], $_POST["password"]); ?? validate_has_arguments($_POST["email"], $_POST["password"])
if ($response !== null) break; ?? $user_manager->check_login($_POST["email"], $_POST["password"]);
[$response, $uuid] = $user_manager->check_login($_POST["email"], $_POST["password"]); if ($response->satisfied) $_SESSION["uuid"] = $response->payload["uuid"];
if ($uuid !== null) $_SESSION["uuid"] = $uuid;
break; break;
case "logout": case "logout":
$response = validate_csrf() ?? validate_logged_in(); $response = validate_csrf() ?? validate_logged_in();
@ -206,14 +205,31 @@ if (isset($_POST["action"])) {
} }
} elseif (isset($_GET["action"])) { } elseif (isset($_GET["action"])) {
// GET requests; do not alter state // GET requests; do not alter state
$response = match ($_GET["action"]) { switch ($_GET["action"]) {
"get-user-data" => validate_logged_in() ?? $user_manager->get_user_data($_SESSION["uuid"]), case "start-session":
"list-trackings" => validate_logged_in() ?? $tracking_manager->list_trackings($_SESSION["uuid"]), if (!isset($_SESSION["uuid"])) {
default => new Response( $response = new Response(payload: null, satisfied: true);
payload: ["target" => null, "message" => "Unknown GET action '" . $_GET["action"] . "'."], break;
satisfied: false }
),
}; $response = $user_manager->get_user_data($_SESSION["uuid"]);
if (!$response->satisfied) {
session_destroy();
session_start();
}
break;
case "get-user-data":
$response = validate_logged_in() ?? $user_manager->get_user_data($_SESSION["uuid"]);
break;
case "list-trackings":
$response = validate_logged_in() ?? $tracking_manager->list_trackings($_SESSION["uuid"]);
break;
default:
$response = new Response(
payload: ["target" => null, "message" => "Unknown GET action '" . $_GET["action"] . "'."],
satisfied: false
);
}
} elseif ($argc > 1) { } elseif ($argc > 1) {
// CLI // CLI
if (hash_equals($config["admin"]["update_secret"], "REPLACE THIS WITH A SECRET VALUE")) if (hash_equals($config["admin"]["update_secret"], "REPLACE THIS WITH A SECRET VALUE"))

View File

@ -4,11 +4,21 @@
} }
table form, table button { /* Trackings table */
#trackings form, #trackings button, #addTrackingPersonName {
margin-bottom: unset; margin-bottom: unset;
} }
#addTrackingPersonName, #addTrackingButton {
display: inline;
}
#addTrackingPersonName {
width: unset;
}
/* Input validation elements */
.validationInfo { .validationInfo {
display: block; display: block;
font-weight: normal; font-weight: normal;

View File

@ -34,9 +34,12 @@
<section class="container"> <section class="container">
<div class="row"> <div class="row">
<p id="validationInfoShared"> <div class="column">
<span class="validationInfo"></span> <!-- Move form-specific messages closer to the form! -->
</p> <p id="validationInfoShared">
<span class="validationInfo"></span>
</p>
</div>
</div> </div>
<div class="row hidden" id="loginRow"> <div class="row hidden" id="loginRow">
@ -54,7 +57,7 @@
<input id="loginPassword" type="password" name="password" /> <input id="loginPassword" type="password" name="password" />
<span class="validationInfo"></span> <span class="validationInfo"></span>
</label> </label>
<button>Log in</button> <button id="loginButton">Log in</button>
</form> </form>
</div> </div>
<div class="column"> <div class="column">
@ -81,7 +84,7 @@
<input id="registerPasswordConfirm" type="password" name="passwordConfirm" /> <input id="registerPasswordConfirm" type="password" name="passwordConfirm" />
<span class="validationInfo"></span> <span class="validationInfo"></span>
</label> </label>
<button>Create account</button> <button id="registerButton">Create account</button>
</form> </form>
</div> </div>
</div> </div>
@ -99,18 +102,16 @@
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td> <td colspan="3">
<form id="addTrackingForm" novalidate> <form id="addTrackingForm" novalidate>
<label for="addTrackingPersonName"> <label for="addTrackingPersonName">
<input id="addTrackingPersonName" type="text" name="personName" <input id="addTrackingPersonName" type="text" name="personName"
autocomplete="on" /> autocomplete="on" />
<button id="addTrackingButton">Add</button>
<span class="validationInfo"></span> <span class="validationInfo"></span>
</label> </label>
<button>Add</button>
</form> </form>
</td> </td>
<td></td>
<td></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -122,25 +123,27 @@
<h2>Manage account</h2> <h2>Manage account</h2>
<!-- TODO: Add way to delete account --> <!-- TODO: Add way to delete account -->
<form id="logoutForm" novalidate> <form id="logoutForm" novalidate>
<button>Log out</button> <button id="logoutButton">Log out</button>
</form> </form>
</div> </div>
<div class="column"> <div class="column">
<h3>Change email</h3> <h3>Change email</h3>
Current email: TODO<br /> <p>
Verified? TODO <b>Current email:</b> <span id="emailCurrent">TODO</span><br />
<b>Validated:</b> <span id="emailValidated">TODO</span>
</p>
<form id="updateEmailForm" novalidate> <form id="updateEmailForm" novalidate>
<label for="updateEmailEmail"> <label for="updateEmailEmail">
Email Email
<input id="updateEmailEmail" type="email" name="email" autocomplete="on" /> <input id="updateEmailEmail" type="email" name="email" autocomplete="on" />
<span class="validationInfo"></span> <span class="validationInfo"></span>
</label> </label>
<button>Change email</button> <button id="updateEmailButton">Change email</button>
</form> </form>
</div> </div>
<div class="column"> <div class="column">
<h3>Change password</h3> <h3>Change password</h3>
Last changed: TODO <p>Last changed: <span id="passwordLastChanged">TODO</span></p>
<form id="updatePasswordForm" novalidate> <form id="updatePasswordForm" novalidate>
<label for="updatePasswordPasswordOld"> <label for="updatePasswordPasswordOld">
Old password Old password
@ -157,7 +160,7 @@
<input id="updatePasswordPasswordConfirm" type="password" name="passwordConfirm" /> <input id="updatePasswordPasswordConfirm" type="password" name="passwordConfirm" />
<span class="validationInfo"></span> <span class="validationInfo"></span>
</label> </label>
<button>Change password</button> <button id="updatePasswordButton">Change password</button>
</form> </form>
</div> </div>
</div> </div>

View File

@ -82,6 +82,15 @@ function showError(element: HTMLElement, message?: string) {
} }
/**
* Sends a GET request to the API.
*
* @param params the GET parameters to send
* @param form the form to display validation info in
* @param onSatisfied the callback to execute if the request returns successfully
* @param onUnsatisfied the callback to execute if the request returns unsuccessfully
* @param onError the callback to execute if there was an HTTP error
*/
function getApi( function getApi(
params: Record<string, string>, params: Record<string, string>,
form: HTMLFormElement, form: HTMLFormElement,
@ -92,6 +101,15 @@ function getApi(
interactWithApi("api.php?" + new URLSearchParams(params), undefined, form, onSatisfied, onUnsatisfied, onError); interactWithApi("api.php?" + new URLSearchParams(params), undefined, form, onSatisfied, onUnsatisfied, onError);
} }
/**
* Sends a POST request to the API.
*
* @param params the POST parameters to send
* @param form the form to display validation info in
* @param onSatisfied the callback to execute if the request returns successfully
* @param onUnsatisfied the callback to execute if the request returns unsuccessfully
* @param onError the callback to execute if there was an HTTP error
*/
function postApi( function postApi(
params: object, params: object,
form: HTMLFormElement, form: HTMLFormElement,
@ -115,6 +133,16 @@ function postApi(
); );
} }
/**
* Sends a request to the API.
*
* @param url the URL to send the request to
* @param options the options to send with the request
* @param form the form to display validation info in
* @param onSatisfied the callback to execute if the request returns successfully
* @param onUnsatisfied the callback to execute if the request returns unsuccessfully
* @param onError the callback to execute if there was an HTTP error
*/
function interactWithApi( function interactWithApi(
url: string, url: string,
options: object | undefined, options: object | undefined,
@ -152,10 +180,11 @@ function interactWithApi(
}); });
} }
/** /**
* Refreshes the list of trackings in the table. * Refreshes the list of trackings in the table.
*/ */
function refreshTrackings() { function refreshTrackings(): void {
getApi( getApi(
{action: "list-trackings"}, {action: "list-trackings"},
sharedMessageElement, sharedMessageElement,
@ -182,9 +211,7 @@ function refreshTrackings() {
const deleteCell = document.createElement("td"); const deleteCell = document.createElement("td");
const deleteForm = document.createElement("form"); const deleteForm = document.createElement("form");
const deleteButton = document.createElement("button"); deleteForm.addEventListener("submit", (event: SubmitEvent) => {
deleteButton.innerText = "remove";
deleteButton.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault(); event.preventDefault();
postApi( postApi(
@ -193,22 +220,36 @@ function refreshTrackings() {
() => refreshTrackings() () => refreshTrackings()
) )
}); });
const deleteButton = document.createElement("button");
deleteButton.innerText = "remove";
deleteForm.append(deleteButton); deleteForm.append(deleteButton);
deleteCell.append(deleteForm); deleteCell.append(deleteForm);
row.append(deleteCell); row.append(deleteCell);
tableBody.insertBefore(row, lastRow); tableBody.insertBefore(row, lastRow);
}); });
},
() => {
// TODO
},
() => {
// TODO
} }
); );
} }
/**
* Refreshes displays of the user's data.
*
* @param userData the most up-to-date information on the user
*/
function refreshUserData(userData: any): void {
// Email
$("#emailCurrent").innerText = userData.email;
// Password update time
const today = new Date();
today.setHours(0, 0, 0, 0)
const updateTime = new Date(userData.password_update_time * 1000);
updateTime.setHours(0, 0, 0, 0);
const diff = (+today - +updateTime) / 86400000;
$("#passwordLastChanged").innerText = diff === 0 ? "today" : diff + " days ago";
}
// Initialize template // Initialize template
doAfterLoad(() => { doAfterLoad(() => {
@ -226,10 +267,12 @@ doAfterLoad(() => {
// Event handlers and so on // Event handlers and so on
doAfterLoad(() => { doAfterLoad(() => {
// Find rows
const loginRow = $("#loginRow"); const loginRow = $("#loginRow");
const trackingRow = $("#trackingRow"); const trackingRow = $("#trackingRow");
const accountRow = $("#accountRow"); const accountRow = $("#accountRow");
// Find forms
const loginForm = $("#loginForm"); const loginForm = $("#loginForm");
const registerForm = $("#registerForm"); const registerForm = $("#registerForm");
const logoutForm = $("#logoutForm"); const logoutForm = $("#logoutForm");
@ -237,6 +280,35 @@ doAfterLoad(() => {
const updatePasswordForm = $("#updatePasswordForm"); const updatePasswordForm = $("#updatePasswordForm");
const addTrackingForm = $("#addTrackingForm"); const addTrackingForm = $("#addTrackingForm");
// Add common event code
function onLogin() {
loginRow.classList.add("hidden")
trackingRow.classList.remove("hidden");
accountRow.classList.remove("hidden");
refreshTrackings();
loginForm.reset();
clearMessages(loginForm);
registerForm.reset();
clearMessages(registerForm);
}
function onLogout() {
loginRow.classList.remove("hidden")
trackingRow.classList.add("hidden");
accountRow.classList.add("hidden");
addTrackingForm.reset();
clearMessages(addTrackingForm);
updateEmailForm.reset();
clearMessages(updateEmailForm);
updatePasswordForm.reset();
clearMessages(updatePasswordForm);
addTrackingForm.reset();
clearMessages(addTrackingForm);
}
// Add event handlers
loginForm.addEventListener("submit", (event: SubmitEvent) => { loginForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault(); event.preventDefault();
@ -248,11 +320,10 @@ doAfterLoad(() => {
password: $("#loginPassword").value, password: $("#loginPassword").value,
}, },
loginForm, loginForm,
() => { (response) => {
loginRow.classList.add("hidden"); $("#emailCurrent").innerText = response.payload.email;
trackingRow.classList.remove("hidden"); $("#passwordLastChanged").innerText = response.payload.password_last_update;
accountRow.classList.remove("hidden"); onLogin();
refreshTrackings();
} }
); );
}); });
@ -285,11 +356,7 @@ doAfterLoad(() => {
token: csrfToken, token: csrfToken,
}, },
logoutForm, logoutForm,
() => { () => onLogout()
loginRow.classList.remove("hidden");
trackingRow.classList.add("hidden");
accountRow.classList.add("hidden");
}
); );
}); });
@ -303,7 +370,11 @@ doAfterLoad(() => {
email: $("#updateEmailEmail").value, email: $("#updateEmailEmail").value,
}, },
updateEmailForm, updateEmailForm,
() => sharedMessageElement.innerText = "Email updated successfully!" () => {
$("#emailCurrent").innerText = $("#updateEmailEmail").value;
updateEmailForm.reset();
showSuccess(sharedMessageElement, "Email updated successfully!");
}
); );
}); });
@ -314,12 +385,16 @@ doAfterLoad(() => {
{ {
action: "update-password", action: "update-password",
token: csrfToken, token: csrfToken,
password_old: $("#updatePasswordPasswordOld"), password_old: $("#updatePasswordPasswordOld").value,
password_new: $("#updatePasswordPasswordNew"), password_new: $("#updatePasswordPasswordNew").value,
password_confirm: $("#updatePasswordPasswordConfirm"), password_confirm: $("#updatePasswordPasswordConfirm").value,
}, },
updatePasswordForm, updatePasswordForm,
() => sharedMessageElement.innerText = "Password updated successfully!" () => {
$("#passwordLastChanged").innerText = "today";
updatePasswordForm.reset();
showSuccess(sharedMessageElement, "Password updated successfully!");
}
); );
}); });
@ -340,21 +415,17 @@ doAfterLoad(() => {
); );
}); });
// Show content depending on whether user is logged in
// TODO: Add appropriate message when session is expired
// TODO: Log out if session is expired
getApi( getApi(
// TODO: Rename to `start-session` for semantic reasons? {action: "start-session"},
{action: "get-user-data"},
sharedMessageElement, sharedMessageElement,
() => { (response: ServerResponse) => {
trackingRow.classList.remove("hidden"); if (response.payload === null) {
accountRow.classList.remove("hidden"); onLogout();
refreshTrackings(); } else {
}, onLogin();
() => { refreshUserData(response.payload);
clearMessage(sharedMessageElement); }
loginRow.classList.remove("hidden")
} }
); );
}); });

View File

@ -64,6 +64,11 @@ class TrackingManager
*/ */
public function add_tracking(string $user_uuid, string $person_name): Response public function add_tracking(string $user_uuid, string $person_name): Response
{ {
if (trim($person_name) === "")
return new Response(
payload: ["target" => "personName", "message" => "Invalid page name: empty."],
satisfied: false
);
if (strlen($person_name) > self::MAX_TITLE_LENGTH) if (strlen($person_name) > self::MAX_TITLE_LENGTH)
return new Response( return new Response(
payload: ["target" => "personName", "message" => "Invalid page name: too long."], payload: ["target" => "personName", "message" => "Invalid page name: too long."],

View File

@ -57,7 +57,8 @@ class UserManager
public function install(): void public function install(): void
{ {
$conn = Database::connect($this->db_filename); $conn = Database::connect($this->db_filename);
$conn->exec("CREATE TABLE users(uuid text primary key not null, email text not null, password text not null);"); $conn->exec("CREATE TABLE users(uuid text primary key not null, email text not null,
password text not null, password_update_time int not null);");
} }
/** /**
@ -117,7 +118,8 @@ class UserManager
} }
// Register user // Register user
$stmt = $conn->prepare("INSERT INTO users (uuid, email, password) VALUES (:uuid, :email, :password);"); $stmt = $conn->prepare("INSERT INTO users (uuid, email, password, password_update_time)
VALUES (:uuid, :email, :password, unixepoch());");
$stmt->bindValue(":uuid", $uuid); $stmt->bindValue(":uuid", $uuid);
$stmt->bindValue(":email", $email); $stmt->bindValue(":email", $email);
$stmt->bindValue(":password", password_hash($password, PASSWORD_DEFAULT)); $stmt->bindValue(":password", password_hash($password, PASSWORD_DEFAULT));
@ -151,44 +153,38 @@ class UserManager
* *
* @param string $email the email address of the user whose password should be checked * @param string $email the email address of the user whose password should be checked
* @param string $password the password to check against the specified user * @param string $password the password to check against the specified user
* @return array{Response, ?string} the first element is a response with message `null` if the login was successful, * @return Response a response with user data if the login was successful, or a response with a message explaining
* or a response with a message explaining what went wrong otherwise; the second element is the UUID of the user * what went wrong otherwise
* that was logged in as, or `null` if the login should not be performed
*/ */
public function check_login(string $email, string $password): array public function check_login(string $email, string $password): Response
{ {
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > self::MAX_EMAIL_LENGTH) if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > self::MAX_EMAIL_LENGTH)
return [ return new Response(
new Response( payload: ["target" => "email", "message" => "Invalid email address."],
payload: ["target" => "email", "message" => "Invalid email address."], satisfied: false
satisfied: false );
),
null
];
if (strlen($password) > self::MAX_PASSWORD_LENGTH) if (strlen($password) > self::MAX_PASSWORD_LENGTH)
return [ return new Response(
new Response( payload: ["target" => "password", "message" => "Incorrect combination of email and password."],
payload: ["target" => "password", "message" => "Incorrect combination of email and password."], satisfied: false
satisfied: false );
),
null
];
$conn = Database::connect($this->db_filename); $conn = Database::connect($this->db_filename);
$stmt = $conn->prepare("SELECT uuid, password FROM users WHERE email=:email;"); $stmt = $conn->prepare("SELECT uuid, email, password, password_update_time FROM users WHERE email=:email;");
$stmt->bindValue(":email", $email); $stmt->bindValue(":email", $email);
$stmt->execute(); $stmt->execute();
$results = $stmt->fetchAll(PDO::FETCH_ASSOC); $results = $stmt->fetchAll(PDO::FETCH_ASSOC);
return (sizeof($results) === 0 || !password_verify($password, $results[0]["password"])) if (sizeof($results) === 0 || !password_verify($password, $results[0]["password"])) {
? [ return new Response(
new Response( payload: ["target" => "password", "message" => "Incorrect combination of email and password."],
payload: ["target" => "password", "message" => "Incorrect combination of email and password."], satisfied: false
satisfied: false );
), }
null
] $user = $results[0];
: [new Response(payload: null, satisfied: true), $results[0]["uuid"]]; unset($user["password"]);
return new Response(payload: $user, satisfied: true);
} }
/** /**
@ -200,12 +196,15 @@ class UserManager
public function get_user_data(string $uuid): Response public function get_user_data(string $uuid): Response
{ {
$conn = Database::connect($this->db_filename); $conn = Database::connect($this->db_filename);
$stmt = $conn->prepare("SELECT * FROM users WHERE uuid=:uuid;"); $stmt = $conn->prepare("SELECT uuid, email, password_update_time FROM users WHERE uuid=:uuid;");
$stmt->bindValue(":uuid", $uuid); $stmt->bindValue(":uuid", $uuid);
$stmt->execute(); $stmt->execute();
$user = $stmt->fetch(PDO::FETCH_ASSOC); $user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user === false) if ($user === false)
return new Response(payload: ["target" => "uuid", "message" => "Invalid user."], satisfied: false); return new Response(
payload: ["target" => "uuid", "message" => "Something went wrong. Please try logging in again."],
satisfied: false
);
return new Response(payload: $user, satisfied: true); return new Response(payload: $user, satisfied: true);
} }
@ -305,7 +304,8 @@ class UserManager
} }
// Update password // Update password
$stmt = $conn->prepare("UPDATE users SET password=:password WHERE uuid=:uuid;"); $stmt = $conn->prepare("UPDATE users SET password=:password, password_update_time=unixepoch()
WHERE uuid=:uuid;");
$stmt->bindValue(":uuid", $uuid); $stmt->bindValue(":uuid", $uuid);
$stmt->bindValue(":password", password_hash($password_new, PASSWORD_DEFAULT)); $stmt->bindValue(":password", password_hash($password_new, PASSWORD_DEFAULT));
$stmt->execute(); $stmt->execute();