diff options
Diffstat (limited to 'Timeline/ClientApp/src/app/data')
-rw-r--r-- | Timeline/ClientApp/src/app/data/base64.ts | 9 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/data/common.ts | 25 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/data/timeline.ts | 345 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/data/user.ts | 224 |
4 files changed, 603 insertions, 0 deletions
diff --git a/Timeline/ClientApp/src/app/data/base64.ts b/Timeline/ClientApp/src/app/data/base64.ts new file mode 100644 index 00000000..7f7c6fcc --- /dev/null +++ b/Timeline/ClientApp/src/app/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/app/data/common.ts b/Timeline/ClientApp/src/app/data/common.ts new file mode 100644 index 00000000..1cdf93f1 --- /dev/null +++ b/Timeline/ClientApp/src/app/data/common.ts @@ -0,0 +1,25 @@ +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 interface CommonErrorResponse { + code: number; + message: string; +} + +export function extractErrorCode(error: AxiosError): number | null { + const { response } = error as AxiosError<CommonErrorResponse>; + const code = response && response.data && response.data.code; + if (typeof code === 'number') { + return code; + } else { + return null; + } +} diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts new file mode 100644 index 00000000..19bb3d45 --- /dev/null +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -0,0 +1,345 @@ +import axios from 'axios'; +import XRegExp from 'xregexp'; + +import { base64 } from './base64'; +import { apiBaseUrl } from '../config'; +import { User, UserAuthInfo, getCurrentUser, UserWithToken } from './user'; +import { UiLogicError } from '../common'; + +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 UiLogicError('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') { + void 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/app/data/user.ts b/Timeline/ClientApp/src/app/data/user.ts new file mode 100644 index 00000000..8f787478 --- /dev/null +++ b/Timeline/ClientApp/src/app/data/user.ts @@ -0,0 +1,224 @@ +import axios, { AxiosError } from 'axios'; +import { useState, useEffect } from 'react'; +import { BehaviorSubject, Observable } from 'rxjs'; + +import { apiBaseUrl } from '../config'; +import { extractErrorCode } from './common'; +import { pushAlert } from '../common/alert-service'; +import { i18nPromise } from '../i18n'; +import { UiLogicError } from '../common'; + +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 UiLogicError("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, + }; + void i18nPromise.then((t) => { + pushAlert({ + type: 'success', + message: t('user.welcomeBack'), + }); + }); + return user; + }, + (e: AxiosError) => { + if (e.response != null) { + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + void i18nPromise.then((t) => { + pushAlert({ + type: 'danger', + message: t('user.verifyTokenFailed'), + }); + }); + } else { + void 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 UiLogicError('Already login.'); + } + return axios + .post<CreateTokenResponse>(createTokenUrl, { ...credentials, expire: 30 }) + .catch((e: AxiosError) => { + if (extractErrorCode(e) === 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 UiLogicError('Please check user first.'); + } + if (getCurrentUser() === null) { + throw new UiLogicError('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 UiLogicError( + "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 UiLogicError( + "This is a logic error in user module. User emitted can't be undefined later." + ); + } + setUser(u); + }); + return () => { + sub.unsubscribe(); + }; + }); + return user; +} + +export function useUserLoggedIn(): UserWithToken { + const user = useUser(); + if (user == null) { + throw new UiLogicError('You assert user has logged in but actually not.'); + } + return user; +} + +export function fetchUser(username: string): Promise<User> { + return axios + .get<User>(`${apiBaseUrl}/users/${username}`) + .then((res) => res.data); +} |