Send relation actions through AJAX, closes #93.

This commit is contained in:
flash 2018-12-30 23:07:32 +01:00
parent eed548313b
commit 3da99a37b6
10 changed files with 207 additions and 62 deletions

View file

@ -7,7 +7,7 @@
min-width: 80px;
text-align: center;
cursor: pointer;
transition: color .2s, background-color .2s;
transition: color .2s, background-color .2s, opacity .2s;
color: var(--accent-colour);
border: 1px solid var(--accent-colour);
border-radius: 2px;
@ -23,6 +23,10 @@
background-color: var(--accent-colour);
}
&--busy {
opacity: .4;
}
&--disabled {
--accent-colour: #333;
}

View file

@ -47,32 +47,25 @@ function resetForm(form: HTMLFormElement, defaults: FormHiddenDefault[] = []): v
}
}
function getRawCSRFTokenList(): CSRFToken[]
{
const csrfTokenList: HTMLDivElement = document.getElementById('js-csrf-tokens') as HTMLDivElement;
if (!csrfTokenList)
return [];
return JSON.parse(csrfTokenList.textContent) as CSRFToken[];
}
class CSRFToken {
realm: string;
token: string;
}
const CSRFTokenStore: CSRFToken[] = [];
let CSRFTokenStore: CSRFToken[] = [];
function initCSRF(): void {
const csrfTokens: NodeListOf<HTMLInputElement> = document.querySelectorAll('[name^="csrf["]'),
regex = /\[([A-Za-z0-9_-]+)\]/iu,
handled: string[] = [];
for (let i = 0; i < csrfTokens.length; i++) {
let csrfToken: HTMLInputElement = csrfTokens[i],
matches: string[] = csrfToken.name.match(regex);
if (matches.length < 2)
continue;
let realm: string = matches[1];
if (handled.indexOf(realm) >= 0)
continue;
handled.push(realm);
setCSRF(realm, csrfToken.value);
}
CSRFTokenStore = getRawCSRFTokenList();
}
function getCSRF(realm: string): CSRFToken {

View file

@ -0,0 +1,110 @@
enum UserRelationType {
None = 0,
Follow = 1,
}
interface UserRelationInfo {
user_id: number;
subject_id: number;
relation_type: UserRelationType;
error: string;
message: string;
}
function userRelationsInit(): void
{
const relationButtons: HTMLCollectionOf<HTMLElement> = document.getElementsByClassName('js-user-relation-action') as HTMLCollectionOf<HTMLElement>;
for (let i = 0; i < relationButtons.length; i++) {
switch (relationButtons[i].tagName.toLowerCase()) {
case 'a':
const anchor: HTMLAnchorElement = relationButtons[i] as HTMLAnchorElement;
anchor.removeAttribute('href');
anchor.removeAttribute('target');
anchor.removeAttribute('rel');
break;
}
relationButtons[i].addEventListener('click', userRelationSetEventHandler);
}
}
function userRelationSetEventHandler(ev: Event): void {
const target: HTMLElement = this as HTMLElement,
userId: number = parseInt(target.dataset.relationUser),
relationType: UserRelationType = parseInt(target.dataset.relationType),
isButton: boolean = target.classList.contains('input__button'),
buttonBusy: string = 'input__button--busy';
if (isButton) {
if (target.classList.contains(buttonBusy))
return;
target.classList.add(buttonBusy);
}
userRelationSet(
userId,
relationType,
info => {
target.classList.remove(buttonBusy);
switch (info.relation_type) {
case UserRelationType.None:
if (isButton) {
if (target.classList.contains('input__button--destroy'))
target.classList.remove('input__button--destroy');
target.textContent = 'Follow';
}
target.dataset.relationType = UserRelationType.Follow.toString();
break;
case UserRelationType.Follow:
if (isButton) {
if (!target.classList.contains('input__button--destroy'))
target.classList.add('input__button--destroy');
target.textContent = 'Unfollow';
}
target.dataset.relationType = UserRelationType.None.toString();
break;
}
},
msg => {
target.classList.remove(buttonBusy);
alert(msg);
}
);
}
function userRelationSet(
userId: number,
relationType: UserRelationType,
onSuccess: (info: UserRelationInfo) => void = null,
onFail: (message: string) => void = null
): void {
const xhr: XMLHttpRequest = new XMLHttpRequest;
xhr.addEventListener('readystatechange', () => {
if (xhr.readyState !== 4)
return;
updateCSRF(xhr.getResponseHeader('X-Misuzu-CSRF'));
let json: UserRelationInfo = JSON.parse(xhr.responseText) as UserRelationInfo,
message = json.error || json.message;
if (message && onFail)
onFail(message);
else if (!message && onSuccess)
onSuccess(json);
});
xhr.open('GET', `/relations.php?u=${userId}&m=${relationType}`);
xhr.setRequestHeader('X-Misuzu-XHR', 'user_relation');
xhr.setRequestHeader('X-Misuzu-CSRF', getCSRFToken('user_relation'));
xhr.send();
}

View file

@ -5,6 +5,8 @@
/// <reference path="Comments.ts" />
/// <reference path="Common.ts" />
/// <reference path="FormUtilities.ts" />
/// <reference path="UserRelations.ts" />
declare const timeago: any;
declare const hljs: any;
@ -18,6 +20,7 @@ window.addEventListener('load', () => {
initCSRF();
userInit();
userRelationsInit();
const changelogChangeAction: HTMLDivElement = document.querySelector('.changelog__change__action') as HTMLDivElement;

View file

@ -1,44 +1,62 @@
<?php
require_once '../misuzu.php';
if (empty($_SERVER['HTTP_REFERER']) || !is_local_url($_SERVER['HTTP_REFERER'])) {
header('Location: /');
// basing whether or not this is an xhr request on whether a referrer header is present
// this page is never directy accessed, under normal circumstances
$redirect = !empty($_SERVER['HTTP_REFERER']) && empty($_SERVER['HTTP_X_MISUZU_XHR']) ? $_SERVER['HTTP_REFERER'] : '';
$isXHR = !$redirect;
if ($isXHR) {
header('Content-Type: application/json; charset=utf-8');
} elseif (!is_local_url($redirect)) {
echo render_info('Possible request forgery detected.', 403);
return;
}
if (!csrf_verify('user_relation', $_SERVER['HTTP_X_MISUZU_CSRF'] ?? ($_GET['c'] ?? ''))) {
echo render_info_or_json($isXHR, "Couldn't verify this request, please refresh the page and try again.", 403);
return;
}
header(csrf_http_header('user_relation'));
if (!user_session_active()) {
echo render_error(401);
echo render_info_or_json($isXHR, 'You must be logged in to manage relations.', 401);
return;
}
if (user_warning_check_expiration(user_session_current('user_id', 0), MSZ_WARN_BAN) > 0) {
echo render_error(403);
$userId = (int)user_session_current('user_id');
if (user_warning_check_expiration($userId, MSZ_WARN_BAN) > 0) {
echo render_info_or_json($isXHR, 'You have been banned, check your profile for more information.', 403);
return;
}
$subjectId = (int)($_GET['u'] ?? 0);
$relationType = (int)($_GET['m'] ?? -1);
switch ($_GET['m'] ?? null) {
case 'add':
switch ($_GET['t'] ?? null) {
case 'follow':
default:
$type = MSZ_USER_RELATION_FOLLOW;
break;
}
if (user_relation_add(user_session_current('user_id', 0), $subjectId, $type) !== MSZ_USER_RELATION_E_OK) {
echo render_error(500);
return;
}
break;
case 'remove':
if (!user_relation_remove(user_session_current('user_id', 0), $subjectId)) {
echo render_error(500);
return;
}
break;
if (!user_relation_is_valid_type($relationType)) {
echo render_info_or_json($isXHR, 'Invalid relation type.', 400);
return;
}
header('Location: ' . $_SERVER['HTTP_REFERER']);
if ($userId < 1 || $subjectId < 1) {
echo render_info_or_json($isXHR, "That user doesn't exist.", 400);
return;
}
if (!user_relation_set($userId, $subjectId, $relationType)) {
echo render_info_or_json($isXHR, "Failed to save relation.", 500);
return;
}
if (!$isXHR) {
header('Location: ' . $redirect);
return;
}
echo json_encode([
'user_id' => $userId,
'subject_id' => $subjectId,
'relation_type' => $relationType,
]);

View file

@ -39,6 +39,7 @@ final class TwigMisuzu extends Twig_Extension
new Twig_Function('sql_query_count', 'db_query_count'),
new Twig_Function('url_construct', 'url_construct'),
new Twig_Function('warning_has_duration', 'user_warning_has_duration'),
new Twig_Function('get_csrf_tokens', 'csrf_get_list'),
new Twig_Function('startup_time', function (float $time = MSZ_STARTUP) {
return microtime(true) - $time;
}),

View file

@ -1,23 +1,25 @@
<?php
define('MSZ_USER_RELATION_NONE', 0);
define('MSZ_USER_RELATION_FOLLOW', 1);
define('MSZ_USER_RELATION_TYPES', [
MSZ_USER_RELATION_NONE,
MSZ_USER_RELATION_FOLLOW,
]);
define('MSZ_USER_RELATION_E_OK', 0);
define('MSZ_USER_RELATION_E_USER_ID', 1);
define('MSZ_USER_RELATION_E_DATABASE', 2);
define('MSZ_USER_RELATION_E_INVALID_TYPE', 2);
function user_relation_add(int $userId, int $subjectId, int $type = MSZ_USER_RELATION_FOLLOW): int
function user_relation_is_valid_type(int $type): bool
{
if ($userId < 1 || $subjectId < 1) {
return MSZ_USER_RELATION_E_USER_ID;
return in_array($type, MSZ_USER_RELATION_TYPES, true);
}
function user_relation_set(int $userId, int $subjectId, int $type = MSZ_USER_RELATION_FOLLOW): bool
{
if ($type === MSZ_USER_RELATION_NONE) {
return user_relation_remove($userId, $subjectId);
}
if (!in_array($type, MSZ_USER_RELATION_TYPES, true)) {
return MSZ_USER_RELATION_E_INVALID_TYPE;
if ($userId < 1 || $subjectId < 1 || !user_relation_is_valid_type($type)) {
return false;
}
$addRelation = db_prepare('
@ -31,9 +33,7 @@ function user_relation_add(int $userId, int $subjectId, int $type = MSZ_USER_REL
$addRelation->bindValue('type', $type);
$addRelation->execute();
return $addRelation->execute()
? MSZ_USER_RELATION_E_OK
: MSZ_USER_RELATION_E_DATABASE;
return $addRelation->execute();
}
function user_relation_remove(int $userId, int $subjectId): bool

View file

@ -119,3 +119,16 @@ function csrf_http_header(string $realm, string $name = 'X-Misuzu-CSRF'): string
{
return "{$name}: {$realm};" . csrf_token($realm);
}
function csrf_get_list(): array
{
$list = [];
if (!empty($GLOBALS[MSZ_CSRF_TOKEN_STORE])) {
foreach ($GLOBALS[MSZ_CSRF_TOKEN_STORE] as $realm => $token) {
$list[] = compact('realm', 'token');
}
}
return $list;
}

View file

@ -42,6 +42,9 @@
{{ current_user|json_encode|raw }}
</script>
{% endif %}
<script type="application/json" id="js-csrf-tokens">
{{ get_csrf_tokens()|json_encode|raw }}
</script>
<script src="{{ '/js/libraries.js'|asset_url }}"></script>
<script src="{{ '/js/misuzu.js'|asset_url }}"></script>
</body>

View file

@ -69,9 +69,9 @@
{% if current_user is defined and current_user.user_id != profile.user_id and not is_editing %}
{% if friend_info.user_relation == constant('MSZ_USER_RELATION_FOLLOW') %}
<a href="/relations.php?u={{ profile.user_id }}&amp;m=remove" class="input__button input__button--destroy profile__header__action">{{ friend_info.subject_relation == constant('MSZ_USER_RELATION_FOLLOW') ? 'Unfriend' : 'Unfollow' }}</a>
<a href="/relations.php?u={{ profile.user_id }}&amp;m={{ constant('MSZ_USER_RELATION_NONE') }}&amp;c={{ csrf_token('user_relation') }}" class="input__button input__button--destroy profile__header__action js-user-relation-action" data-relation-user="{{ profile.user_id }}" data-relation-type="{{ constant('MSZ_USER_RELATION_NONE') }}">Unfollow</a>
{% else %}
<a href="/relations.php?u={{ profile.user_id }}&amp;m=add&amp;t=follow" class="input__button profile__header__action">{{ friend_info.subject_relation == constant('MSZ_USER_RELATION_FOLLOW') ? 'Add as Friend' : 'Follow' }}</a>
<a href="/relations.php?u={{ profile.user_id }}&amp;m={{ constant('MSZ_USER_RELATION_FOLLOW') }}&amp;c={{ csrf_token('user_relation') }}" class="input__button profile__header__action js-user-relation-action" data-relation-user="{{ profile.user_id }}" data-relation-type="{{ constant('MSZ_USER_RELATION_FOLLOW') }}">Follow</a>
{% endif %}
{% endif %}
</div>