From 68ca8b0976efe90c0c40bcae69f0825671b98bad Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 30 May 2020 16:23:25 +0800 Subject: Merge front end to this repo. But I need to wait for aspnet core support for custom port and package manager for dev server. --- Timeline/ClientApp/src/data/base64.ts | 9 + Timeline/ClientApp/src/data/common.ts | 20 ++ Timeline/ClientApp/src/data/timeline.ts | 344 ++++++++++++++++++++++++++++++++ Timeline/ClientApp/src/data/user.ts | 215 ++++++++++++++++++++ 4 files changed, 588 insertions(+) create mode 100644 Timeline/ClientApp/src/data/base64.ts create mode 100644 Timeline/ClientApp/src/data/common.ts create mode 100644 Timeline/ClientApp/src/data/timeline.ts create mode 100644 Timeline/ClientApp/src/data/user.ts (limited to 'Timeline/ClientApp/src/data') 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 { + return new Promise(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 { + const user = this.checkUser(); + + return axios + .patch(`${this.urlResolver(name)}?token=${user.token}`, req) + .then((res) => res.data); + } + + fetch(name: string): Promise { + return axios + .get(`${this.urlResolver(name)}`) + .then((res) => res.data); + } + + fetchPosts(name: string): Promise { + const token = getCurrentUser()?.token; + return axios + .get( + 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 { + const user = this.checkUser(); + + const rawReq: Promise = 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( + `${this.urlResolver(name)}/posts?token=${user.token}`, + req + ) + ) + .then((res) => processRawTimelinePostInfo(res.data, user.token)); + } + + deletePost(name: string, id: number): Promise { + const user = this.checkUser(); + + return axios.delete( + `${this.urlResolver(name)}/posts/${id}?token=${user.token}` + ); + } + + addMember(name: string, username: string): Promise { + const user = this.checkUser(); + + return axios.put( + `${this.urlResolver(name)}/members/${username}?token=${user.token}` + ); + } + + removeMember(name: string, username: string): Promise { + 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( + undefined +); + +export const user$: Observable = 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 { + return axios + .post(verifyTokenUrl, { + token: token + } as VerifyTokenRequest) + .then(res => res.data.user); +} + +const TOKEN_STORAGE_KEY = 'token'; + +export function checkUserLoginState(): Promise { + 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 { + if (getCurrentUser()) { + throw new Error('Already login.'); + } + return axios + .post(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( + userSubject.value + ); + useEffect(() => { + const sub = user$.subscribe(u => setUser(u)); + return () => { + sub.unsubscribe(); + }; + }); + return user; +} + +export function useUser(): UserWithToken | null { + const [user, setUser] = useState(() => { + 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 { + return axios + .get(`${apiBaseUrl}/users/${username}`) + .then(res => res.data); +} -- cgit v1.2.3