diff --git a/assets/less/classes/input/button.less b/assets/less/classes/input/button.less index 79555c2f..19275ca8 100644 --- a/assets/less/classes/input/button.less +++ b/assets/less/classes/input/button.less @@ -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; } diff --git a/assets/typescript/FormUtilities.ts b/assets/typescript/FormUtilities.ts index 30069849..9d3d5675 100644 --- a/assets/typescript/FormUtilities.ts +++ b/assets/typescript/FormUtilities.ts @@ -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 = 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 { diff --git a/assets/typescript/UserRelations.ts b/assets/typescript/UserRelations.ts new file mode 100644 index 00000000..94e37909 --- /dev/null +++ b/assets/typescript/UserRelations.ts @@ -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 = document.getElementsByClassName('js-user-relation-action') as HTMLCollectionOf; + + 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(); +} diff --git a/assets/typescript/misuzu.ts b/assets/typescript/misuzu.ts index 056e1ba2..ed2fadb5 100644 --- a/assets/typescript/misuzu.ts +++ b/assets/typescript/misuzu.ts @@ -5,6 +5,8 @@ /// /// /// +/// + 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; diff --git a/public/relations.php b/public/relations.php index a227838d..f223266c 100644 --- a/public/relations.php +++ b/public/relations.php @@ -1,44 +1,62 @@ 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, +]); diff --git a/src/TwigMisuzu.php b/src/TwigMisuzu.php index bb228a4a..49acc77e 100644 --- a/src/TwigMisuzu.php +++ b/src/TwigMisuzu.php @@ -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; }), diff --git a/src/Users/relations.php b/src/Users/relations.php index 8fde18d7..edc554d9 100644 --- a/src/Users/relations.php +++ b/src/Users/relations.php @@ -1,23 +1,25 @@ 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 diff --git a/src/csrf.php b/src/csrf.php index 7548b2b7..b2bbdc15 100644 --- a/src/csrf.php +++ b/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; +} diff --git a/templates/master.twig b/templates/master.twig index 33445a74..85d9a783 100644 --- a/templates/master.twig +++ b/templates/master.twig @@ -42,6 +42,9 @@ {{ current_user|json_encode|raw }} {% endif %} + diff --git a/templates/user/_layout/header.twig b/templates/user/_layout/header.twig index 079019b6..4a95308b 100644 --- a/templates/user/_layout/header.twig +++ b/templates/user/_layout/header.twig @@ -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') %} - {{ friend_info.subject_relation == constant('MSZ_USER_RELATION_FOLLOW') ? 'Unfriend' : 'Unfollow' }} + Unfollow {% else %} - {{ friend_info.subject_relation == constant('MSZ_USER_RELATION_FOLLOW') ? 'Add as Friend' : 'Follow' }} + Follow {% endif %} {% endif %}