Send relation actions through AJAX, closes #93.
This commit is contained in:
parent
eed548313b
commit
3da99a37b6
10 changed files with 207 additions and 62 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
110
assets/typescript/UserRelations.ts
Normal file
110
assets/typescript/UserRelations.ts
Normal 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();
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
@ -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;
|
||||
}),
|
||||
|
|
|
@ -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
|
||||
|
|
13
src/csrf.php
13
src/csrf.php
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 }}&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 }}&m={{ constant('MSZ_USER_RELATION_NONE') }}&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 }}&m=add&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 }}&m={{ constant('MSZ_USER_RELATION_FOLLOW') }}&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>
|
||||
|
|
Loading…
Add table
Reference in a new issue