aboutsummaryrefslogtreecommitdiff
path: root/Timeline/ClientApp/src/data
diff options
context:
space:
mode:
Diffstat (limited to 'Timeline/ClientApp/src/data')
-rw-r--r--Timeline/ClientApp/src/data/base64.ts9
-rw-r--r--Timeline/ClientApp/src/data/common.ts20
-rw-r--r--Timeline/ClientApp/src/data/timeline.ts344
-rw-r--r--Timeline/ClientApp/src/data/user.ts215
4 files changed, 588 insertions, 0 deletions
diff --git a/Timeline/ClientApp/src/data/base64.ts b/Timeline/ClientApp/src/data/base64.ts
new file mode 100644
index 00000000..6d846527
--- /dev/null
+++ b/Timeline/ClientApp/src/data/base64.ts
@@ -0,0 +1,9 @@
+export function base64(blob: Blob): Promise<string> {
+ return new Promise<string>(resolve => {
+ const reader = new FileReader();
+ reader.onload = function() {
+ resolve((reader.result as string).replace(/^data:.+;base64,/, ''));
+ };
+ reader.readAsDataURL(blob);
+ });
+}
diff --git a/Timeline/ClientApp/src/data/common.ts b/Timeline/ClientApp/src/data/common.ts
new file mode 100644
index 00000000..61db8bd2
--- /dev/null
+++ b/Timeline/ClientApp/src/data/common.ts
@@ -0,0 +1,20 @@
+import { AxiosError } from 'axios';
+
+export function extractStatusCode(error: AxiosError): number | null {
+ const code = error.response && error.response.status;
+ if (typeof code === 'number') {
+ return code;
+ } else {
+ return null;
+ }
+}
+
+export function extractErrorCode(error: AxiosError): number | null {
+ const code =
+ error.response && error.response.data && error.response.data.code;
+ if (typeof code === 'number') {
+ return code;
+ } else {
+ return null;
+ }
+}
diff --git a/Timeline/ClientApp/src/data/timeline.ts b/Timeline/ClientApp/src/data/timeline.ts
new file mode 100644
index 00000000..bc5e1658
--- /dev/null
+++ b/Timeline/ClientApp/src/data/timeline.ts
@@ -0,0 +1,344 @@
+import axios from 'axios';
+import XRegExp from 'xregexp';
+
+import { base64 } from './base64';
+import { apiBaseUrl } from '../config';
+import { User, UserAuthInfo, getCurrentUser, UserWithToken } from './user';
+
+export const kTimelineVisibilities = ['Public', 'Register', 'Private'] as const;
+
+export type TimelineVisibility = typeof kTimelineVisibilities[number];
+
+export const timelineVisibilityTooltipTranslationMap: Record<
+ TimelineVisibility,
+ string
+> = {
+ Public: 'timeline.visibilityTooltip.public',
+ Register: 'timeline.visibilityTooltip.register',
+ Private: 'timeline.visibilityTooltip.private',
+};
+
+export interface TimelineInfo {
+ name: string;
+ description: string;
+ owner: User;
+ visibility: TimelineVisibility;
+ members: User[];
+ _links: {
+ posts: string;
+ };
+}
+
+export interface TimelinePostTextContent {
+ type: 'text';
+ text: string;
+}
+
+export interface TimelinePostImageContent {
+ type: 'image';
+ url: string;
+}
+
+export type TimelinePostContent =
+ | TimelinePostTextContent
+ | TimelinePostImageContent;
+
+export interface TimelinePostInfo {
+ id: number;
+ content: TimelinePostContent;
+ time: Date;
+ author: User;
+}
+
+export interface CreatePostRequestTextContent {
+ type: 'text';
+ text: string;
+}
+
+export interface CreatePostRequestImageContent {
+ type: 'image';
+ data: Blob;
+}
+
+export type CreatePostRequestContent =
+ | CreatePostRequestTextContent
+ | CreatePostRequestImageContent;
+
+export interface CreatePostRequest {
+ content: CreatePostRequestContent;
+ time?: Date;
+}
+
+// TODO: Remove in the future
+export interface TimelineChangePropertyRequest {
+ visibility?: TimelineVisibility;
+ description?: string;
+}
+
+export interface PersonalTimelineChangePropertyRequest {
+ visibility?: TimelineVisibility;
+ description?: string;
+}
+
+export interface OrdinaryTimelineChangePropertyRequest {
+ // not supported by server now
+ // name?: string;
+ visibility?: TimelineVisibility;
+ description?: string;
+}
+
+//-------------------- begin: internal model --------------------
+
+interface RawTimelinePostTextContent {
+ type: 'text';
+ text: string;
+}
+
+interface RawTimelinePostImageContent {
+ type: 'image';
+ url: string;
+}
+
+type RawTimelinePostContent =
+ | RawTimelinePostTextContent
+ | RawTimelinePostImageContent;
+
+interface RawTimelinePostInfo {
+ id: number;
+ content: RawTimelinePostContent;
+ time: string;
+ author: User;
+}
+
+interface RawCreatePostRequestTextContent {
+ type: 'text';
+ text: string;
+}
+
+interface RawCreatePostRequestImageContent {
+ type: 'text';
+ data: string;
+}
+
+type RawCreatePostRequestContent =
+ | RawCreatePostRequestTextContent
+ | RawCreatePostRequestImageContent;
+
+interface RawCreatePostRequest {
+ content: RawCreatePostRequestContent;
+ time?: string;
+}
+
+//-------------------- end: internal model --------------------
+
+function processRawTimelinePostInfo(
+ raw: RawTimelinePostInfo,
+ token?: string
+): TimelinePostInfo {
+ return {
+ ...raw,
+ content: (() => {
+ if (raw.content.type === 'image' && token != null) {
+ return {
+ ...raw.content,
+ url: raw.content.url + '?token=' + token,
+ };
+ }
+ return raw.content;
+ })(),
+ time: new Date(raw.time),
+ };
+}
+
+type TimelineUrlResolver = (name: string) => string;
+
+export class TimelineServiceTemplate<
+ TTimeline extends TimelineInfo,
+ TChangePropertyRequest
+> {
+ private checkUser(): UserWithToken {
+ const user = getCurrentUser();
+ if (user == null) {
+ throw new Error('You must login to perform the operation.');
+ }
+ return user;
+ }
+
+ constructor(private urlResolver: TimelineUrlResolver) {}
+
+ changeProperty(
+ name: string,
+ req: TChangePropertyRequest
+ ): Promise<TTimeline> {
+ const user = this.checkUser();
+
+ return axios
+ .patch<TTimeline>(`${this.urlResolver(name)}?token=${user.token}`, req)
+ .then((res) => res.data);
+ }
+
+ fetch(name: string): Promise<TTimeline> {
+ return axios
+ .get<TTimeline>(`${this.urlResolver(name)}`)
+ .then((res) => res.data);
+ }
+
+ fetchPosts(name: string): Promise<TimelinePostInfo[]> {
+ const token = getCurrentUser()?.token;
+ return axios
+ .get<RawTimelinePostInfo[]>(
+ token == null
+ ? `${this.urlResolver(name)}/posts`
+ : `${this.urlResolver(name)}/posts?token=${token}`
+ )
+ .then((res) => res.data.map((p) => processRawTimelinePostInfo(p, token)));
+ }
+
+ createPost(
+ name: string,
+ request: CreatePostRequest
+ ): Promise<TimelinePostInfo> {
+ const user = this.checkUser();
+
+ const rawReq: Promise<RawCreatePostRequest> = new Promise<
+ RawCreatePostRequestContent
+ >((resolve) => {
+ if (request.content.type === 'image') {
+ base64(request.content.data).then((d) =>
+ resolve({
+ ...request.content,
+ data: d,
+ } as RawCreatePostRequestImageContent)
+ );
+ } else {
+ resolve(request.content);
+ }
+ }).then((content) => {
+ const rawReq: RawCreatePostRequest = {
+ content,
+ };
+ if (request.time != null) {
+ rawReq.time = request.time.toISOString();
+ }
+ return rawReq;
+ });
+
+ return rawReq
+ .then((req) =>
+ axios.post<RawTimelinePostInfo>(
+ `${this.urlResolver(name)}/posts?token=${user.token}`,
+ req
+ )
+ )
+ .then((res) => processRawTimelinePostInfo(res.data, user.token));
+ }
+
+ deletePost(name: string, id: number): Promise<void> {
+ const user = this.checkUser();
+
+ return axios.delete(
+ `${this.urlResolver(name)}/posts/${id}?token=${user.token}`
+ );
+ }
+
+ addMember(name: string, username: string): Promise<void> {
+ const user = this.checkUser();
+
+ return axios.put(
+ `${this.urlResolver(name)}/members/${username}?token=${user.token}`
+ );
+ }
+
+ removeMember(name: string, username: string): Promise<void> {
+ const user = this.checkUser();
+
+ return axios.delete(
+ `${this.urlResolver(name)}/members/${username}?token=${user.token}`
+ );
+ }
+
+ isMemberOf(username: string, timeline: TTimeline): boolean {
+ return timeline.members.findIndex((m) => m.username == username) >= 0;
+ }
+
+ hasReadPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TTimeline
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ const { visibility } = timeline;
+ if (visibility === 'Public') {
+ return true;
+ } else if (visibility === 'Register') {
+ if (user != null) return true;
+ } else if (visibility === 'Private') {
+ if (user != null && this.isMemberOf(user.username, timeline)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ hasPostPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TTimeline
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return (
+ user != null &&
+ (timeline.owner.username === user.username ||
+ this.isMemberOf(user.username, timeline))
+ );
+ }
+
+ hasManagePermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TTimeline
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return user != null && user.username == timeline.owner.username;
+ }
+
+ hasModifyPostPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TTimeline,
+ post: TimelinePostInfo
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return (
+ user != null &&
+ (user.username === timeline.owner.username ||
+ user.username === post.author.username)
+ );
+ }
+}
+
+export type PersonalTimelineService = TimelineServiceTemplate<
+ TimelineInfo,
+ PersonalTimelineChangePropertyRequest
+>;
+
+export const personalTimelineService: PersonalTimelineService = new TimelineServiceTemplate<
+ TimelineInfo,
+ PersonalTimelineChangePropertyRequest
+>((name) => `${apiBaseUrl}/timelines/@${name}`);
+
+export type OrdinaryTimelineService = TimelineServiceTemplate<
+ TimelineInfo,
+ OrdinaryTimelineChangePropertyRequest
+>;
+
+export const ordinaryTimelineService: OrdinaryTimelineService = new TimelineServiceTemplate<
+ TimelineInfo,
+ TimelineChangePropertyRequest
+>((name) => `${apiBaseUrl}/timelines/${name}`);
+
+const timelineNameReg = XRegExp('^[-_\\p{L}]*$', 'u');
+
+export function validateTimelineName(name: string): boolean {
+ return timelineNameReg.test(name);
+}
diff --git a/Timeline/ClientApp/src/data/user.ts b/Timeline/ClientApp/src/data/user.ts
new file mode 100644
index 00000000..755aecf6
--- /dev/null
+++ b/Timeline/ClientApp/src/data/user.ts
@@ -0,0 +1,215 @@
+import axios, { AxiosError } from 'axios';
+import { useState, useEffect } from 'react';
+import { BehaviorSubject, Observable } from 'rxjs';
+
+import { apiBaseUrl } from '../config';
+import { pushAlert } from '../common/alert-service';
+import { i18nPromise } from '../i18n';
+
+export interface UserAuthInfo {
+ username: string;
+ administrator: boolean;
+}
+
+export interface User {
+ username: string;
+ administrator: boolean;
+ nickname: string;
+ _links: {
+ avatar: string;
+ timeline: string;
+ };
+}
+
+export interface UserWithToken extends User {
+ token: string;
+}
+
+interface CreateTokenRequest {
+ username: string;
+ password: string;
+}
+
+interface CreateTokenResponse {
+ token: string;
+ user: User;
+}
+
+interface VerifyTokenRequest {
+ token: string;
+}
+
+interface VerifyTokenResponse {
+ user: User;
+}
+
+export type LoginCredentials = CreateTokenRequest;
+
+const userSubject = new BehaviorSubject<UserWithToken | null | undefined>(
+ undefined
+);
+
+export const user$: Observable<UserWithToken | null | undefined> = userSubject;
+
+export function getCurrentUser(): UserWithToken | null | undefined {
+ return userSubject.value;
+}
+
+const kCreateTokenUrl = '/token/create';
+const kVerifyTokenUrl = '/token/verify';
+const createTokenUrl = apiBaseUrl + kCreateTokenUrl;
+const verifyTokenUrl = apiBaseUrl + kVerifyTokenUrl;
+
+function verifyToken(token: string): Promise<User> {
+ return axios
+ .post<VerifyTokenResponse>(verifyTokenUrl, {
+ token: token
+ } as VerifyTokenRequest)
+ .then(res => res.data.user);
+}
+
+const TOKEN_STORAGE_KEY = 'token';
+
+export function checkUserLoginState(): Promise<UserWithToken | null> {
+ if (getCurrentUser() !== undefined)
+ throw new Error("Already checked user. Can't check twice.");
+
+ const savedToken = window.localStorage.getItem(TOKEN_STORAGE_KEY);
+ if (savedToken) {
+ return verifyToken(savedToken)
+ .then(
+ u => {
+ const user: UserWithToken = {
+ ...u,
+ token: savedToken
+ };
+ i18nPromise.then(t => {
+ pushAlert({
+ type: 'success',
+ message: t('user.welcomeBack')
+ });
+ });
+ return user;
+ },
+ (e: AxiosError) => {
+ if (e.response != null) {
+ window.localStorage.removeItem(TOKEN_STORAGE_KEY);
+ i18nPromise.then(t => {
+ pushAlert({
+ type: 'danger',
+ message: t('user.verifyTokenFailed')
+ });
+ });
+ } else {
+ i18nPromise.then(t => {
+ pushAlert({
+ type: 'danger',
+ message: t('user.verifyTokenFailedNetwork')
+ });
+ });
+ }
+
+ return null;
+ }
+ )
+ .then(u => {
+ userSubject.next(u);
+ return u;
+ });
+ }
+ userSubject.next(null);
+ return Promise.resolve(null);
+}
+
+export class BadCredentialError {
+ constructor(public innerError: Error) {}
+
+ message = 'login.badCredential';
+}
+
+export function userLogin(
+ credentials: LoginCredentials,
+ rememberMe: boolean
+): Promise<UserWithToken> {
+ if (getCurrentUser()) {
+ throw new Error('Already login.');
+ }
+ return axios
+ .post<CreateTokenResponse>(createTokenUrl, { ...credentials, expire: 30 })
+ .catch(e => {
+ const error = e as AxiosError;
+ if (error.response?.data?.code === 11010101) {
+ throw new BadCredentialError(e);
+ }
+ throw e;
+ })
+ .then(res => {
+ const body = res.data;
+ const token = body.token;
+ if (rememberMe) {
+ window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
+ }
+ const user = {
+ ...body.user,
+ token
+ };
+ userSubject.next(user);
+ return user;
+ });
+}
+
+export function userLogout(): void {
+ if (getCurrentUser() === undefined) {
+ throw new Error('Please check user first.');
+ }
+ if (getCurrentUser() === null) {
+ throw new Error('No login.');
+ }
+ window.localStorage.removeItem(TOKEN_STORAGE_KEY);
+ userSubject.next(null);
+}
+
+export function useOptionalUser(): UserWithToken | null | undefined {
+ const [user, setUser] = useState<UserWithToken | null | undefined>(
+ userSubject.value
+ );
+ useEffect(() => {
+ const sub = user$.subscribe(u => setUser(u));
+ return () => {
+ sub.unsubscribe();
+ };
+ });
+ return user;
+}
+
+export function useUser(): UserWithToken | null {
+ const [user, setUser] = useState<UserWithToken | null>(() => {
+ const initUser = userSubject.value;
+ if (initUser === undefined) {
+ throw new Error(
+ "This is a logic error in user module. Current user can't be undefined in useUser."
+ );
+ }
+ return initUser;
+ });
+ useEffect(() => {
+ const sub = user$.subscribe(u => {
+ if (u === undefined) {
+ throw new Error(
+ "This is a logic error in user module. User emitted can't be undefined later."
+ );
+ }
+ setUser(u);
+ });
+ return () => {
+ sub.unsubscribe();
+ };
+ });
+ return user;
+}
+
+export function fetchUser(username: string): Promise<User> {
+ return axios
+ .get<User>(`${apiBaseUrl}/users/${username}`)
+ .then(res => res.data);
+}