Web client refinement (#16)

* fix(files/handler): add base64 decode for content

* fix(singleuser): pick user name from jwt token and encode content

* fix(singleuser): add public path check, abstract user info from token

* fix(singleuser): update singleuser client

* fix(server): fix test and enable auth by default

* feat(client/web): add web client

* fix(client/web): refine css styles

* fix(client/web): refine styles

* fix(client/web): refine styles, add test and fix bugs

* test(client/web): add web client tests

* fix(client/web): refactor client interface and enhance the robustness

* chore(client/web): ignore js bundles

* test(files): call sync before check

Co-authored-by: Jia He <jiah@nvidia.com>
This commit is contained in:
Hexxa 2020-12-16 23:39:26 +08:00 committed by GitHub
parent 0265baf1b1
commit ba6a5373d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 9192 additions and 158 deletions

2
.gitignore vendored
View file

@ -6,3 +6,5 @@
**/dist
**/vendor
**/yarn-error
**/public/static/*/*.js
**/public/static/**/*.js

View file

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<script src="dist/assets.bundle.js"></script>
</head>
<body>
<script src="dist/api_test.bundle.js"></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 KiB

View file

@ -1,16 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
<link rel="stylesheet" type="text/css" href="style.css" />
<script src="dist/assets.bundle.js"></script>
</head>
<body>
<div id="app"></div>
<script src="dist/admin.bundle.js"></script>
</body>
<head>
<meta charset="UTF-8" />
<title>Quickshare</title>
<meta
name="viewport"
content="initial-scale=1.0, maximum-scale=1, minimum-scale=1, user-scalable=no,uc-fitscreen=yes"
/>
<meta class="chrome-color" name="theme-color" content="black" />
<script src="/static/js/react.development.js?v=16.8.6"></script>
<script src="/static/js/react-dom.development.js?v=16.8.6"></script>
<script src="/static/js/immutable.min.js?v=4.0.0-rc.12"></script>
<!-- <link
rel="apple-touch-icon"
sizes="57x57"
href="/static/fav/apple-icon-57x57.png"
/>
<link
rel="apple-touch-icon"
sizes="60x60"
href="/static/fav/apple-icon-60x60.png"
/>
<link
rel="apple-touch-icon"
sizes="72x72"
href="/static/fav/apple-icon-72x72.png"
/>
<link
rel="apple-touch-icon"
sizes="76x76"
href="/static/fav/apple-icon-76x76.png"
/>
<link
rel="apple-touch-icon"
sizes="114x114"
href="/static/fav/apple-icon-114x114.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
href="/static/fav/apple-icon-120x120.png"
/>
<link
rel="apple-touch-icon"
sizes="144x144"
href="/static/fav/apple-icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="152x152"
href="/static/fav/apple-icon-152x152.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/static/fav/apple-icon-180x180.png"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/static/fav/android-icon-192x192.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/static/fav/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/static/fav/favicon-96x96.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/static/fav/favicon-16x16.png"
/>
<link rel="manifest" href="/static/fav/manifest.json" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta
name="msapplication-TileImage"
content="/static/fav/ms-icon-144x144.png"
/> -->
<meta name="theme-color" content="#ffffff" />
</head>
<body>
<div id="content"><div id="mount"></div></div>
<script src="static/default-vendors-node_modules_axios_index_js-node_modules_css-loader_dist_runtime_api_js-node_-e9ca3b.bundle.js?910792307d026e16bec7"></script><script src="static/main.bundle.js?910792307d026e16bec7"></script></body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

BIN
public/static/img/prism.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

View file

@ -1,68 +0,0 @@
html {
background: url("ggb.jpg") no-repeat left center fixed;
background-size: auto 100%;
/* background-image: url("squared_metal.png"); repeat fixed*/
background-color: black;
height: 100%;
}
body {
height: 100%;
min-height: 100%;
color: #333;
font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
font-size: 16px;
margin: 0;
padding: 0;
outline: none;
border: none;
}
a {
outline: none;
border: none;
}
.anm-rotate {
animation: rotate 1s infinite linear;
}
@keyframes rotate {
20% {
transform: rotate(72deg);
}
40% {
transform: rotate(144deg);
}
60% {
transform: rotate(216deg);
}
80% {
transform: rotate(288deg);
}
100% {
transform: rotate(360deg);
}
}
.anm-lighter:hover {
animation: lighter 0.5 1;
}
@keyframes lighter {
0% {
opacity: 100%;
}
50% {
opacity: 50%;
}
}
::-moz-selection {
background: #2ecc71;
color: #fff;
}
::selection {
background: #2ecc71;
color: #fff;
}

View file

@ -34,19 +34,15 @@ func (cl *SingleUserClient) Login(user, pwd string) (*http.Response, string, []e
End()
}
func (cl *SingleUserClient) Logout(user string, token *http.Cookie) (*http.Response, string, []error) {
func (cl *SingleUserClient) Logout(token *http.Cookie) (*http.Response, string, []error) {
return cl.r.Post(cl.url("/v1/users/logout")).
Send(su.LogoutReq{
User: user,
}).
AddCookie(token).
End()
}
func (cl *SingleUserClient) SetPwd(user, oldPwd, newPwd string, token *http.Cookie) (*http.Response, string, []error) {
func (cl *SingleUserClient) SetPwd(oldPwd, newPwd string, token *http.Cookie) (*http.Response, string, []error) {
return cl.r.Patch(cl.url("/v1/users/pwd")).
Send(su.SetPwdReq{
User: user,
OldPwd: oldPwd,
NewPwd: newPwd,
}).

8
src/client/web/.babelrc Normal file
View file

@ -0,0 +1,8 @@
{
"presets": ["@babel/env", "@babel/react"],
"env": {
"test": {
"plugins": ["@babel/plugin-transform-runtime"]
}
}
}

3
src/client/web/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
**/node_modules/**
**/yarn-error.log
**/bundle.js

View file

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Quickshare</title>
<meta
name="viewport"
content="initial-scale=1.0, maximum-scale=1, minimum-scale=1, user-scalable=no,uc-fitscreen=yes"
/>
<meta class="chrome-color" name="theme-color" content="black" />
<script src="/static/js/react.development.js?v=16.8.6"></script>
<script src="/static/js/react-dom.development.js?v=16.8.6"></script>
<script src="/static/js/immutable.min.js?v=4.0.0-rc.12"></script>
<!-- <link
rel="apple-touch-icon"
sizes="57x57"
href="/static/fav/apple-icon-57x57.png"
/>
<link
rel="apple-touch-icon"
sizes="60x60"
href="/static/fav/apple-icon-60x60.png"
/>
<link
rel="apple-touch-icon"
sizes="72x72"
href="/static/fav/apple-icon-72x72.png"
/>
<link
rel="apple-touch-icon"
sizes="76x76"
href="/static/fav/apple-icon-76x76.png"
/>
<link
rel="apple-touch-icon"
sizes="114x114"
href="/static/fav/apple-icon-114x114.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
href="/static/fav/apple-icon-120x120.png"
/>
<link
rel="apple-touch-icon"
sizes="144x144"
href="/static/fav/apple-icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="152x152"
href="/static/fav/apple-icon-152x152.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/static/fav/apple-icon-180x180.png"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/static/fav/android-icon-192x192.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/static/fav/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/static/fav/favicon-96x96.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/static/fav/favicon-16x16.png"
/>
<link rel="manifest" href="/static/fav/manifest.json" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta
name="msapplication-TileImage"
content="/static/fav/ms-icon-144x144.png"
/> -->
<meta name="theme-color" content="#ffffff" />
</head>
<body>
<div id="content"><div id="mount"></div></div>
</body>
</html>

View file

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Quickshare</title>
<meta
name="viewport"
content="initial-scale=1.0, maximum-scale=1, minimum-scale=1, user-scalable=no,uc-fitscreen=yes"
/>
<meta class="chrome-color" name="theme-color" content="black" />
<!-- <script src="/static/js/react.production.min.js?v=16.8.6"></script>
<script src="/static/js/react-dom.production.min.js?v=16.8.6"></script>
<script src="/static/js/immutable.min.js?v=4.0.0-rc.12"></script>
<link
rel="apple-touch-icon"
sizes="57x57"
href="/static/fav/apple-icon-57x57.png"
/>
<link
rel="apple-touch-icon"
sizes="60x60"
href="/static/fav/apple-icon-60x60.png"
/>
<link
rel="apple-touch-icon"
sizes="72x72"
href="/static/fav/apple-icon-72x72.png"
/>
<link
rel="apple-touch-icon"
sizes="76x76"
href="/static/fav/apple-icon-76x76.png"
/>
<link
rel="apple-touch-icon"
sizes="114x114"
href="/static/fav/apple-icon-114x114.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
href="/static/fav/apple-icon-120x120.png"
/>
<link
rel="apple-touch-icon"
sizes="144x144"
href="/static/fav/apple-icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="152x152"
href="/static/fav/apple-icon-152x152.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/static/fav/apple-icon-180x180.png"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/static/fav/android-icon-192x192.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/static/fav/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/static/fav/favicon-96x96.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/static/fav/favicon-16x16.png"
/>
<link rel="manifest" href="/static/fav/manifest.json" /> -->
<!-- <meta name="msapplication-TileColor" content="#ffffff" /> -->
<!-- <meta
name="msapplication-TileImage"
content="/static/fav/ms-icon-144x144.png"
/> -->
<!-- <meta name="theme-color" content="#ffffff" /> -->
</head>
<body>
<div id="content"><div id="mount"></div></div>
</body>
</html>

View file

@ -0,0 +1,83 @@
{
"name": "web",
"version": "0.2.0",
"description": "web client for quickshare",
"main": "",
"scripts": {
"build": "webpack --config webpack.app.prod.js",
"build:watch": "webpack --config webpack.app.prod.js --watch",
"build:dev": "webpack --config webpack.app.dev.js --watch",
"build:admin": "webpack --config webpack.admin.prod.js",
"build:admin:watch": "webpack --config webpack.admin.prod.js --watch",
"build:admin:dev": "webpack --config webpack.admin.dev.js --watch",
"build:task": "webpack --config webpack.task.prod.js",
"build:task:watch": "webpack --config webpack.task.prod.js --watch",
"build:task:dev": "webpack --config webpack.task.dev.js --watch",
"e2e": "jest -c jest.e2e.config.js",
"e2e:watch": "jest --watch -c jest.e2e.config.js",
"test": "jest test",
"test:watch": "jest test --watch",
"copy": "cp -r ../../static ../../../dockers/nginx/"
},
"author": "hexxa",
"license": "LGPL-3.0",
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.4.4",
"@babel/preset-env": "^7.4.4",
"@babel/preset-react": "^7.0.0",
"@types/assert": "^1.4.2",
"@types/deep-diff": "^1.0.0",
"@types/jest": "^24.0.12",
"assert": "^2.0.0",
"css-loader": "^2.1.1",
"deep-diff": "^1.0.2",
"html-webpack-plugin": "^4.0.0-beta.5",
"jest": "^24.8.0",
"source-map-loader": "^0.2.4",
"style-loader": "^0.23.1",
"terser-webpack-plugin": "^1.3.0",
"ts-jest": "^24.0.2",
"ts-loader": "^6.0.0",
"ts-node": "^8.2.0",
"tslint": "^5.16.0",
"typescript": "^3.4.3",
"uglifyjs-webpack-plugin": "^2.1.3",
"webpack": "^5.0.0-rc.6",
"webpack-bundle-analyzer": "^3.3.2",
"webpack-cli": "^3.3.0",
"webpack-merge": "^4.2.1"
},
"dependencies": {
"@types/axios": "^0.14.0",
"@types/immutable": "^3.8.7",
"@types/react": "^16.8.13",
"@types/react-copy-to-clipboard": "^4.2.6",
"@types/react-dom": "^16.8.4",
"@types/react-svg": "^5.0.0",
"@types/throttle-debounce": "^1.1.1",
"axios": "^0.18.0",
"filesize": "^6.1.0",
"immutable": "^4.0.0-rc.12",
"react": "^16.8.6",
"react-copy-to-clipboard": "^5.0.1",
"react-dom": "^16.8.6",
"react-svg": "^8.0.6",
"throttle-debounce": "^2.1.0"
},
"jest": {
"testMatch": [
"**/src/**/__test__/**/*.test.ts",
"**/src/**/__test__/**/*.test.tsx"
],
"transform": {
"\\.(ts|tsx)$": "ts-jest"
},
"verbose": true,
"moduleFileExtensions": [
"ts",
"tsx",
"js"
]
},
"autoBump": {}
}

View file

@ -0,0 +1,14 @@
import * as React from "react";
import * as ReactDOM from "react-dom";
import { StateMgr } from "./components/state_mgr";
import "./theme/reset.css";
import "./theme/white.css";
// TODO: it fails in jest preprocessor now
import "./theme/style.css";
import "./theme/desktop.css";
import "./theme/color.css";
ReactDOM.render(<StateMgr />, document.getElementById("mount"));

View file

@ -0,0 +1,104 @@
import {
BaseClient,
Response,
UploadStatusResp,
ListResp,
} from "./";
const filePathQuery = "fp";
const listDirQuery = "dp";
// TODO: get timeout from server
export class FilesClient extends BaseClient {
constructor(url: string) {
super(url);
}
create = (filePath: string, fileSize: number): Promise<Response> => {
return this.do({
method: "post",
url: `${this.url}/v1/fs/files`,
data: {
path: filePath,
fileSize: fileSize,
},
});
};
delete = (filePath: string): Promise<Response> => {
return this.do({
method: "delete",
url: `${this.url}/v1/fs/files`,
params: {
[filePathQuery]: filePath,
},
});
};
metadata = (filePath: string): Promise<Response> => {
return this.do({
method: "get",
url: `${this.url}/v1/fs/metadata`,
params: {
[filePathQuery]: filePath,
},
});
};
mkdir = (dirpath: string): Promise<Response> => {
return this.do({
method: "post",
url: `${this.url}/v1/fs/dirs`,
data: {
path: dirpath,
},
});
};
move = (oldPath: string, newPath: string): Promise<Response> => {
return this.do({
method: "patch",
url: `${this.url}/v1/fs/files/move`,
data: {
oldPath,
newPath,
},
});
};
uploadChunk = (
filePath: string,
content: string | ArrayBuffer,
offset: number
): Promise<Response<UploadStatusResp>> => {
return this.do({
method: "patch",
url: `${this.url}/v1/fs/files/chunks`,
data: {
path: filePath,
content,
offset,
},
});
};
uploadStatus = (filePath: string): Promise<Response<UploadStatusResp>> => {
return this.do({
method: "get",
url: `${this.url}/v1/fs/files/chunks`,
params: {
[filePathQuery]: filePath,
},
});
};
list = (dirPath: string): Promise<Response<ListResp>> => {
return this.do({
method: "get",
url: `${this.url}/v1/fs/dirs`,
params: {
[listDirQuery]: dirPath,
},
});
};
}

View file

@ -0,0 +1,82 @@
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
import {MetadataResp, UploadStatusResp, ListResp} from "./";
export class FilesClient {
private url: string;
private createMockResp: number;
private deleteMockResp: number;
private metadataMockResp: MetadataResp | null;
private mkdirMockResp: number | null;
private moveMockResp: number;
private uploadChunkMockResp: UploadStatusResp | null;
private uploadStatusMockResp: UploadStatusResp | null;
private listMockResp: ListResp | null;
constructor(url: string) {
this.url = url;
}
createMock = (resp: number) => {
this.createMockResp = resp;
}
deleteMock = (resp: number) => {
this.deleteMockResp = resp;
}
metadataMock = (resp: MetadataResp | null) => {
this.metadataMockResp = resp;
}
mkdirMock = (resp: number | null) => {
this.mkdirMockResp = resp;
}
moveMock = (resp: number) => {
this.moveMockResp = resp;
}
uploadChunkMock = (resp: UploadStatusResp | null) => {
this.uploadChunkMockResp = resp;
}
uploadStatusMock = (resp: UploadStatusResp | null) => {
this.uploadStatusMockResp = resp;
}
listMock = (resp: ListResp | null) => {
this.listMockResp = resp;
}
async create(filePath: string, fileSize: number): Promise<number> {
return this.createMockResp;
}
async delete(filePath: string): Promise<number> {
return this.deleteMockResp;
}
async metadata(filePath: string): Promise<MetadataResp | null> {
return this.metadataMockResp;
}
async mkdir(dirpath: string): Promise<number | null> {
return this.mkdirMockResp;
}
async move(oldPath: string, newPath: string): Promise<number> {
return this.moveMockResp;
}
async uploadChunk(
filePath: string,
content: string | ArrayBuffer,
offset: number
): Promise<UploadStatusResp | null> {
return this.uploadChunkMockResp;
}
async uploadStatus(filePath: string): Promise<UploadStatusResp | null> {
return this.uploadStatusMockResp;
}
async list(dirPath: string): Promise<ListResp | null> {
return this.listMockResp;
}
}

View file

@ -0,0 +1,110 @@
import axios, { AxiosRequestConfig } from "axios";
export const defaultTimeout = 10000;
export interface MetadataResp {
name: string;
size: number;
modTime: string;
isDir: boolean;
}
export interface UploadStatusResp {
path: string;
isDir: boolean;
fileSize: number;
uploaded: number;
}
export interface ListResp {
metadatas: MetadataResp[];
}
export interface IUsersClient {
login: (user: string, pwd: string) => Promise<Response>
logout: () => Promise<Response>
isAuthed: () => Promise<Response>
setPwd: (oldPwd: string, newPwd: string) => Promise<Response>
}
export interface IFilesClient {
create: (filePath: string, fileSize: number) => Promise<Response>;
delete: (filePath: string) => Promise<Response>;
metadata: (filePath: string) => Promise<Response>;
mkdir: (dirpath: string) => Promise<Response>;
move: (oldPath: string, newPath: string) => Promise<Response>;
uploadChunk: (
filePath: string,
content: string | ArrayBuffer,
offset: number
) => Promise<Response<UploadStatusResp>>;
uploadStatus: (filePath: string) => Promise<Response<UploadStatusResp>>;
list: (dirPath: string) => Promise<Response<ListResp>>;
}
export interface Response<T = any> {
status: number;
statusText: string;
data: T;
}
export const TimeoutResp: Response<any> = {
status: 408,
data: {},
statusText: "Request Timeout",
};
// 6xx are custom errors for expressing errors which can not be expressed by http status code
export const EmptyBodyResp: Response<any> = {
status: 601,
data: {},
statusText: "Empty Response Body",
};
export const UnknownErrResp = (errMsg: string): Response<any> => {
return {
status: 600,
data: {},
statusText: errMsg,
};
};
export class BaseClient {
protected url: string;
constructor(url: string) {
this.url = url;
}
async do(config: AxiosRequestConfig): Promise<Response> {
let returned = false;
const src = axios.CancelToken.source();
return new Promise((resolve: (ret: Response) => void) => {
setTimeout(() => {
if (!returned) {
src.cancel("request timeout");
// resolve(TimeoutResp);
}
}, defaultTimeout);
axios({ ...config, cancelToken: src.token })
.then((resp) => {
returned = true;
resolve(resp);
})
.catch((e) => {
const errMsg = e.toString();
console.log(e);
if (errMsg.includes("i/o timeput")) {
resolve(TimeoutResp);
} else if (errMsg.includes("ERR_EMPTY")) {
resolve(EmptyBodyResp);
} else {
resolve(UnknownErrResp(errMsg));
}
});
});
}
}

View file

@ -0,0 +1,109 @@
import { List } from "immutable";
import { Response, UnknownErrResp, UploadStatusResp } from "./";
import { FilesClient } from "../client/files";
const defaultChunkLen = 1024 * 1024 * 30; // 15MB/s
const speedDownRatio = 0.5;
const speedUpRatio = 1.1;
export class FileUploader {
private reader = new FileReader();
private client = new FilesClient("");
private file: File;
private filePath: string;
private offset: number;
private chunkLen: number = defaultChunkLen;
private progressCb: (filePath: string, progress: number) => void;
private errMsg: string | null;
constructor(
file: File,
filePath: string,
progressCb: (filePath: string, progress: number) => void
) {
this.file = file;
this.filePath = filePath;
this.progressCb = progressCb;
this.offset = 0;
}
err = (): string | null => {
return this.errMsg;
}
start = async (): Promise<boolean> => {
const resp = await this.client.create(this.filePath, this.file.size);
switch (resp.status) {
case 200:
return await this.upload();
default:
this.errMsg = `failed to create ${this.filePath}: status=${resp.statusText}`;
return false;
}
};
upload = async (): Promise<boolean> => {
while (
this.chunkLen > 0 &&
this.offset >= 0 &&
this.offset < this.file.size
) {
let uploadPromise = new Promise<Response<UploadStatusResp>>(
(resolve: (resp: Response<UploadStatusResp>) => void) => {
this.reader.onerror = (ev: ProgressEvent<FileReader>) => {
resolve(UnknownErrResp(this.reader.error.toString()));
};
this.reader.onloadend = (ev: ProgressEvent<FileReader>) => {
const dataURL = ev.target.result as string; // readAsDataURL
const base64Chunk = dataURL.slice(dataURL.indexOf(",") + 1);
this.client
.uploadChunk(this.filePath, base64Chunk, this.offset)
.then((resp: Response<UploadStatusResp>) => {
resolve(resp);
})
.catch((e) => {
resolve(UnknownErrResp(e.toString()));
});
};
}
);
const chunkRightPos =
this.offset + this.chunkLen > this.file.size
? this.file.size
: this.offset + this.chunkLen;
const blob = this.file.slice(this.offset, chunkRightPos);
this.reader.readAsDataURL(blob);
const uploadResp = await uploadPromise;
if (uploadResp.status === 200 && uploadResp.data != null) {
this.offset = uploadResp.data.uploaded;
this.chunkLen = Math.ceil(this.chunkLen * speedUpRatio);
} else {
this.errMsg = uploadResp.statusText;
this.chunkLen = Math.ceil(this.chunkLen * speedDownRatio);
const uploadStatusResp = await this.client.uploadStatus(this.filePath);
console.log(uploadStatusResp.status);
if (uploadStatusResp.status === 200) {
this.offset = uploadStatusResp.data.uploaded;
} else if (uploadStatusResp.status === 600) {
this.errMsg = "unknown error";
break
} else {
// do nothing and retry
}
}
this.progressCb(this.filePath, Math.ceil(this.offset / this.file.size));
}
if (this.chunkLen === 0) {
this.errMsg = "network is bad, please retry later.";
}
return this.offset === this.file.size;
};
}

View file

@ -0,0 +1,46 @@
import { BaseClient, Response } from "./";
export class UsersClient extends BaseClient {
constructor(url: string) {
super(url);
}
login = (user: string, pwd: string): Promise<Response> => {
return this.do({
method: "post",
url: `${this.url}/v1/users/login`,
data: {
user,
pwd,
},
});
}
// token cookie is set by browser
logout = (): Promise<Response> => {
return this.do({
method: "post",
url: `${this.url}/v1/users/logout`,
});
}
isAuthed = (): Promise<Response> => {
return this.do({
method: "get",
url: `${this.url}/v1/users/isauthed`,
});
}
// token cookie is set by browser
setPwd = (oldPwd: string, newPwd: string): Promise<Response> => {
return this.do({
method: "patch",
url: `${this.url}/v1/users/pwd`,
data: {
oldPwd,
newPwd,
},
});
}
}

View file

@ -0,0 +1,45 @@
// import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
// TODO: replace this with jest mocks
export class MockUsersClient {
private url: string;
private loginResp: number;
private logoutResp: number;
private isAuthedResp: number;
private setPwdResp: number;
constructor(url: string) {
this.url = url;
}
mockLoginResp(status: number) {
this.loginResp = status;
}
mocklogoutResp(status: number) {
this.logoutResp = status;
}
mockisAuthedResp(status: number) {
this.isAuthedResp = status;
}
mocksetPwdResp(status: number) {
this.setPwdResp = status;
}
async login(user: string, pwd: string): Promise<number> {
return this.loginResp
}
// token cookie is set by browser
async logout(): Promise<number> {
return this.logoutResp
}
async isAuthed(): Promise<number> {
return this.isAuthedResp
}
// token cookie is set by browser
async setPwd(oldPwd: string, newPwd: string): Promise<number> {
return this.setPwdResp
}
}

View file

@ -0,0 +1,7 @@
export const range = (start: number, end: number): Array<number> => {
let array = new Array(0);
for (let i = start; i <= end; i++) {
array.push(i);
}
return array;
};

View file

@ -0,0 +1,32 @@
import * as React from "react";
import { init } from "../core_state";
import { Updater } from "../auth_pane";
import { MockUsersClient } from "../../client/users_mock";
describe("AuthPane", () => {
test("Updater: initIsAuthed", () => {
const tests = [
{
loginStatus: 200,
isAuthed: true,
},
{
loginStatus: 500,
isAuthed: false,
},
];
const client = new MockUsersClient("foobarurl");
Updater.setClient(client);
const coreState = init();
tests.forEach(async (tc) => {
client.mockisAuthedResp(tc.loginStatus);
await Updater.initIsAuthed().then(() => {
const newState = Updater.setAuthPane(coreState);
expect(newState.panel.authPane.authed).toEqual(tc.isAuthed);
});
});
});
});

View file

@ -0,0 +1,10 @@
import * as React from "react";
import { init } from "../core_state";
import { Updater } from "../browser";
import { MockUsersClient } from "../../client/users_mock";
describe("Browser", () => {
test("Updater: ", () => {
});
});

View file

@ -0,0 +1,151 @@
import * as React from "react";
import { ICoreState } from "./core_state";
import { IUsersClient } from "../client";
import { UsersClient } from "../client/users";
export interface Props {
authed: boolean;
update?: (updater: (prevState: ICoreState) => ICoreState) => void;
}
export class Updater {
private static props: Props;
private static client: IUsersClient;
static init = (props: Props) => (Updater.props = { ...props });
static setClient = (client: IUsersClient): void => {
Updater.client = client;
};
static login = async (user: string, pwd: string): Promise<boolean> => {
const resp = await Updater.client.login(user, pwd);
Updater.setAuthed(resp.status === 200);
return resp.status === 200;
};
static logout = async (): Promise<boolean> => {
const resp = await Updater.client.logout();
Updater.setAuthed(false);
return resp.status === 200;
};
static isAuthed = async (): Promise<boolean> => {
const resp = await Updater.client.isAuthed();
return resp.status === 200;
};
static initIsAuthed = async (): Promise<void> => {
return Updater.isAuthed().then((isAuthed) => {
Updater.setAuthed(isAuthed);
});
};
static setAuthed = (isAuthed: boolean) => {
Updater.props.authed = isAuthed;
};
static setAuthPane = (preState: ICoreState): ICoreState => {
preState.panel.authPane = {
...preState.panel.authPane,
...Updater.props,
};
return preState;
};
}
export interface State {
user: string;
pwd: string;
}
export class AuthPane extends React.Component<Props, State, {}> {
private update: (updater: (prevState: ICoreState) => ICoreState) => void;
constructor(p: Props) {
super(p);
Updater.init(p);
Updater.setClient(new UsersClient(""));
this.update = p.update;
this.state = {
user: "",
pwd: "",
};
this.initIsAuthed();
}
changeUser = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ user: ev.target.value });
};
changePwd = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ pwd: ev.target.value });
};
initIsAuthed = () => {
Updater.initIsAuthed().then(() => {
this.update(Updater.setAuthPane);
});
};
login = () => {
Updater.login(this.state.user, this.state.pwd).then((ok: boolean) => {
if (ok) {
this.update(Updater.setAuthPane);
this.setState({ user: "", pwd: "" });
} else {
alert("Failed to login.");
}
});
};
logout = () => {
Updater.logout().then((ok: boolean) => {
if (ok) {
this.update(Updater.setAuthPane);
this.setState({ user: "", pwd: "" });
} else {
alert("Failed to login.");
}
});
};
render() {
return (
<span>
<span style={{ display: this.props.authed ? "none" : "inherit" }}>
<input
name="user"
type="text"
onChange={this.changeUser}
value={this.state.user}
className="margin-r-m black0-font"
style={{ width: "6rem" }}
placeholder="user name"
/>
<input
name="pwd"
type="password"
onChange={this.changePwd}
value={this.state.pwd}
className="margin-r-m black0-font"
style={{ width: "6rem" }}
placeholder="password"
/>
<button onClick={this.login} className="green0-bg white-font">
Log in
</button>
</span>
<span style={{ display: this.props.authed ? "inherit" : "none" }}>
<button
onClick={this.logout}
className="grey1-bg white-font margin-r-m"
>
Log out
</button>
</span>
</span>
);
}
}

View file

@ -0,0 +1,549 @@
import * as React from "react";
import * as ReactDOM from "react-dom";
import { List, Map } from "immutable";
import * as Filesize from "filesize";
import { ICoreState } from "./core_state";
import { IUsersClient, IFilesClient, MetadataResp } from "../client";
import { FilesClient } from "../client/files";
import { UsersClient } from "../client/users";
import { FileUploader } from "../client/uploader";
export const uploadCheckCycle = 1000;
export interface Item {
name: string;
size: number;
modTime: string;
isDir: boolean;
selected: boolean;
}
export interface Props {
dirPath: List<string>;
items: List<MetadataResp>;
uploadFiles: List<File>;
uploadValue: string;
update?: (updater: (prevState: ICoreState) => ICoreState) => void;
}
function getItemPath(dirPath: string, itemName: string): string {
return dirPath.endsWith("/")
? `${dirPath}${itemName}`
: `${dirPath}/${itemName}`;
}
export class Updater {
private static props: Props;
private static usersClient: IUsersClient;
private static filesClient: IFilesClient;
static init = (props: Props) => (Updater.props = { ...props });
static setClients(usersClient: IUsersClient, filesClient: IFilesClient) {
Updater.usersClient = usersClient;
Updater.filesClient = filesClient;
}
static setItems = async (dirParts: List<string>): Promise<void> => {
let dirPath = dirParts.join("/");
let listResp = await Updater.filesClient.list(dirPath);
Updater.props.dirPath = dirParts;
Updater.props.items =
listResp != null
? List<MetadataResp>(listResp.data.metadatas)
: Updater.props.items;
};
static mkDir = async (dirPath: string): Promise<void> => {
let resp = await Updater.filesClient.mkdir(dirPath);
if (resp.status !== 200) {
alert(`failed to make dir ${dirPath}`);
}
};
static delete = async (
dirParts: List<string>,
items: List<MetadataResp>,
selectedItems: Map<string, boolean>
): Promise<void> => {
const delRequests = items
.filter((item) => {
return selectedItems.has(item.name);
})
.map(
async (selectedItem: MetadataResp): Promise<string> => {
const itemPath = getItemPath(dirParts.join("/"), selectedItem.name);
const resp = await Updater.filesClient.delete(itemPath);
return resp.status === 200 ? "" : selectedItem.name;
}
);
const failedFiles = await Promise.all(delRequests);
failedFiles.forEach((failedFile) => {
if (failedFile !== "") {
alert(`failed to delete ${failedFile}`);
}
});
return Updater.setItems(dirParts);
};
static moveHere = async (
srcDir: string,
dstDir: string,
selectedItems: Map<string, boolean>
): Promise<void> => {
const moveRequests = List<string>(selectedItems.keys()).map(
async (itemName: string): Promise<string> => {
const oldPath = getItemPath(srcDir, itemName);
const newPath = getItemPath(dstDir, itemName);
const resp = await Updater.filesClient.move(oldPath, newPath);
return resp.status === 200 ? "" : itemName;
}
);
const failedFiles = await Promise.all(moveRequests);
failedFiles.forEach((failedItem) => {
if (failedItem !== "") {
alert(`failed to move ${failedItem}`);
}
});
return Updater.setItems(List<string>(dstDir.split("/")));
};
static setPwd = async (oldPwd: string, newPwd: string): Promise<boolean> => {
const resp = await Updater.usersClient.setPwd(oldPwd, newPwd);
return resp.status === 200;
};
static addUploadFiles = (fileList: FileList, len: number) => {
let newUploads = List<File>([]);
for (let i = 0; i < len; i++) {
newUploads = newUploads.push(fileList.item(i));
}
Updater.props.uploadFiles = Updater.props.uploadFiles.concat(newUploads);
};
static setUploadFiles = (uploadFiles: List<File>) => {
Updater.props.uploadFiles = uploadFiles;
};
static setBrowser = (prevState: ICoreState): ICoreState => {
prevState.panel.browser = { ...prevState.panel, ...Updater.props };
return prevState;
};
}
export interface State {
inputValue: string;
selectedSrc: string;
selectedItems: Map<string, boolean>;
show: boolean;
oldPwd: string;
newPwd1: string;
newPwd2: string;
}
export class Browser extends React.Component<Props, State, {}> {
private update: (updater: (prevState: ICoreState) => ICoreState) => void;
private uploadInput: Element | Text;
private assignInput: (input: Element) => void;
private onClickUpload: () => void;
private uploading: boolean;
constructor(p: Props) {
super(p);
Updater.init(p);
Updater.setClients(new UsersClient(""), new FilesClient(""));
this.update = p.update;
this.state = {
inputValue: "",
selectedSrc: "",
selectedItems: Map<string, boolean>(),
show: false,
oldPwd: "",
newPwd1: "",
newPwd2: "",
};
this.uploadInput = undefined;
this.assignInput = (input) => {
this.uploadInput = ReactDOM.findDOMNode(input);
};
this.onClickUpload = () => {
const uploadInput = this.uploadInput as HTMLButtonElement;
uploadInput.click();
};
Updater.setItems(p.dirPath).then(() => {
this.update(Updater.setBrowser);
});
setInterval(this.pollUploads, uploadCheckCycle);
}
pollUploads = () => {
if (this.props.uploadFiles.size > 0 && !this.uploading) {
this.uploading = true;
const file = this.props.uploadFiles.get(0);
Updater.setUploadFiles(this.props.uploadFiles.slice(1));
this.update(Updater.setBrowser);
const uploader = new FileUploader(
file,
`${this.props.dirPath.join("/")}/${file.name}`,
this.updateProgress
);
uploader.start().then((ok: boolean) => {
Updater.setItems(this.props.dirPath).then(() => {
this.update(Updater.setBrowser);
});
if (!ok) {
alert(`upload failed: ${uploader.err()}`);
}
this.uploading = false;
});
}
};
updateProgress = (filePath: string, progress: number) => {
// update uploading progress in the core state
};
showPane = () => {
this.setState({ show: !this.state.show });
};
changeOldPwd = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ oldPwd: ev.target.value });
};
changeNewPwd1 = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ newPwd1: ev.target.value });
};
changeNewPwd2 = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ newPwd2: ev.target.value });
};
onInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ inputValue: ev.target.value });
};
addUploadFile = (event: React.ChangeEvent<HTMLInputElement>) => {
Updater.addUploadFiles(event.target.files, event.target.files.length);
this.update(Updater.setBrowser);
};
select = (itemName: string) => {
const selectedItems = this.state.selectedItems.has(itemName)
? this.state.selectedItems.delete(itemName)
: this.state.selectedItems.set(itemName, true);
this.setState({
selectedSrc: this.props.dirPath.join("/"),
selectedItems: selectedItems,
});
};
onMkDir = () => {
Updater.mkDir(this.state.inputValue)
.then(() => {
this.setState({ inputValue: "" });
return Updater.setItems(this.props.dirPath);
})
.then(() => {
this.update(Updater.setBrowser);
});
};
delete = () => {
if (this.props.dirPath.join("/") !== this.state.selectedSrc) {
alert("please select file or folder to delete at first");
this.setState({
selectedSrc: this.props.dirPath.join("/"),
selectedItems: Map<string, boolean>(),
});
return;
}
Updater.delete(
this.props.dirPath,
this.props.items,
this.state.selectedItems
).then(() => {
this.update(Updater.setBrowser);
this.setState({
selectedSrc: "",
selectedItems: Map<string, boolean>(),
});
});
};
gotoChild = (childDirName: string) => {
this.chdir(this.props.dirPath.push(childDirName));
};
chdir = (dirPath: List<string>) => {
Updater.setItems(dirPath).then(() => {
this.update(Updater.setBrowser);
});
};
moveHere = () => {
const oldDir = this.state.selectedSrc;
const newDir = this.props.dirPath.join("/");
if (oldDir === newDir) {
alert("source directory is same as destination directory");
return;
}
Updater.moveHere(
this.state.selectedSrc,
this.props.dirPath.join("/"),
this.state.selectedItems
).then(() => {
this.update(Updater.setBrowser);
this.setState({
selectedSrc: "",
selectedItems: Map<string, boolean>(),
});
});
};
setPwd = () => {
if (this.state.newPwd1 !== this.state.newPwd2) {
alert("new passwords are not same");
} else if (this.state.newPwd1 == "") {
alert("new passwords can not be empty");
} else if (this.state.oldPwd == this.state.newPwd1) {
alert("old and new passwords are same");
} else {
Updater.setPwd(this.state.oldPwd, this.state.newPwd1).then(
(ok: boolean) => {
if (ok) {
alert("Password is updated");
} else {
alert("fail to update password");
}
this.setState({
oldPwd: "",
newPwd1: "",
newPwd2: "",
});
}
);
}
};
render() {
const breadcrumb = this.props.dirPath.map(
(pathPart: string, key: number) => {
return (
<span key={pathPart}>
<button
type="button"
onClick={() => this.chdir(this.props.dirPath.slice(0, key + 1))}
className="white-font margin-r-m"
style={{ backgroundColor: "rgba(0, 0, 0, 0.7)" }}
>
{pathPart}
</button>
</span>
);
}
);
const ops = (
<div>
<div className="grey0-font">
<button
type="button"
onClick={() => this.delete()}
className="red0-bg white-font margin-m"
>
Delete Selected
</button>
<span className="margin-s">-</span>
<button
type="button"
onClick={() => this.moveHere()}
className="grey1-bg white-font margin-m"
>
Paste
</button>
<span className="margin-s">-</span>
<button
onClick={this.onClickUpload}
className="green0-bg white-font margin-m"
>
Upload Files
</button>
<span className="margin-s">-</span>
<span className="margin-m">
<input
type="text"
onChange={this.onInputChange}
value={this.state.inputValue}
className="margin-r-m black0-font"
placeholder="folder name"
/>
<button onClick={this.onMkDir} className="grey1-bg white-font">
Create Folder
</button>
</span>
<input
type="file"
onChange={this.addUploadFile}
multiple={true}
value={this.props.uploadValue}
ref={this.assignInput}
className="black0-font hidden margin-m"
/>
<span className="margin-s">-</span>
<button
onClick={this.showPane}
className="grey1-bg white-font margin-m"
>
Settings
</button>
</div>
<div>
<div
style={{ display: this.state.show ? "inherit" : "none" }}
className="margin-t-m"
>
<h3 className="padding-l-s grey0-font">Update Password</h3>
<input
name="old_pwd"
type="password"
onChange={this.changeOldPwd}
value={this.state.oldPwd}
className="margin-m black0-font"
placeholder="old password"
/>
<input
name="new_pwd1"
type="password"
onChange={this.changeNewPwd1}
value={this.state.newPwd1}
className="margin-m black0-font"
placeholder="new password"
/>
<input
name="new_pwd2"
type="password"
onChange={this.changeNewPwd2}
value={this.state.newPwd2}
className="margin-m black0-font"
placeholder="new password again"
/>
<button onClick={this.setPwd} className="grey1-bg white-font">
Update
</button>
</div>
</div>
</div>
);
const itemList = this.props.items.map((item: MetadataResp) => {
const isSelected = this.state.selectedItems.has(item.name);
const dirPath = this.props.dirPath.join("/");
const itemPath = dirPath.endsWith("/")
? `${dirPath}${item.name}`
: `${dirPath}/${item.name}`;
return item.isDir ? (
<tr key={item.name} className={`${isSelected ? "green0-bg" : ""}`}>
<td className="padding-l-l" style={{ width: "3rem" }}>
<span className="dot yellow0-bg"></span>
</td>
<td>
<span
className="item-name"
onClick={() => this.gotoChild(item.name)}
>
{item.name}
</span>
</td>
<td>N/A</td>
<td>{item.modTime.slice(0, item.modTime.indexOf("T"))}</td>
<td>
<button
type="button"
onClick={() => this.select(item.name)}
className="grey1-bg white-font margin-t-m margin-b-m"
>
Select
</button>
</td>
</tr>
) : (
<tr key={item.name} className={`${isSelected ? "green0-bg" : ""}`}>
<td className="padding-l-l" style={{ width: "3rem" }}>
<span className="dot green0-bg"></span>
</td>
<td>
<a
className="item-name"
href={`/v1/fs/files?fp=${itemPath}`}
target="_blank"
>
{item.name}
</a>
</td>
<td>{Filesize(item.size, {round: 0})}</td>
<td>{item.modTime.slice(0, item.modTime.indexOf("T"))}</td>
<td>
<button
type="button"
onClick={() => this.select(item.name)}
className="grey1-bg white-font margin-t-m margin-b-m"
>
Select
</button>
</td>
</tr>
);
});
return (
<div>
<div id="op-bar" className="op-bar">
<div className="margin-l-m margin-r-m">{ops}</div>
</div>
<div id="item-list" className="">
<div className="margin-b-l">{breadcrumb}</div>
<table>
<thead style={{ fontWeight: "bold" }}>
<tr>
<td className="padding-l-l" style={{ width: "3rem" }}>
<span className="dot black-bg"></span>
</td>
<td>Name</td>
<td>File Size</td>
<td>Mod Time</td>
<td>Op</td>
</tr>
</thead>
<tbody>{itemList}</tbody>
<tfoot>
<tr>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
);
}
}

View file

@ -0,0 +1,32 @@
import { List } from "immutable";
import { Props as PanelProps } from "./panel";
import { Item } from "./browser";
export interface IContext {
update: (targetStatePatch: any) => void;
}
export interface ICoreState {
ctx: IContext;
panel: PanelProps;
}
export function init(): ICoreState {
return {
ctx: null,
panel: {
displaying: "browser",
authPane: {
authed: false,
},
browser: {
dirPath: List<string>(["."]),
items: List<Item>([]),
uploadValue: "",
uploadFiles: List<File>([]),
},
},
};
}

View file

@ -0,0 +1,68 @@
import * as React from "react";
import { ICoreState } from "./core_state";
import { Browser, Props as BrowserProps } from "./browser";
import { AuthPane, Props as AuthPaneProps } from "./auth_pane";
export interface Props {
displaying: string;
browser: BrowserProps;
authPane: AuthPaneProps;
update?: (updater: (prevState: ICoreState) => ICoreState) => void;
}
export class Updater {
private static props: Props;
static init = (props: Props) => (Updater.props = { ...props });
static setPanel = (prevState: ICoreState): ICoreState => {
return {
...prevState,
panel: { ...prevState.panel, ...Updater.props },
};
};
}
export interface State {}
export class Panel extends React.Component<Props, State, {}> {
private update: (updater: (prevState: ICoreState) => ICoreState) => void;
constructor(p: Props) {
super(p);
Updater.init(p);
this.update = p.update;
}
render() {
return (
<div className="theme-white desktop">
<div id="bg" className="bg bg-img font-m">
<div
id="top-bar"
className="top-bar cyan1-font padding-t-m padding-b-m padding-l-l padding-r-l"
>
<div className="flex-2col-parent">
<span className="flex-13col h5">Quickshare</span>
<span className="flex-23col text-right">
<AuthPane
authed={this.props.authPane.authed}
update={this.update}
/>
</span>
</div>
</div>
<div id="container-center">
<Browser
dirPath={this.props.browser.dirPath}
items={this.props.browser.items}
update={this.update}
uploadFiles={this.props.browser.uploadFiles}
uploadValue={this.props.browser.uploadValue}
/>
</div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,37 @@
import * as React from "react";
import { ICoreState, init } from "./core_state";
import { Panel } from "./panel";
export interface Props {}
export interface State extends ICoreState {}
export class UpdaterBase {
private static props: any;
static init = (props: any) => (UpdaterBase.props = {...props});
}
export class StateMgr extends React.Component<Props, State, {}> {
constructor(p: Props) {
super(p);
this.state = init();
}
// TODO: any can be eliminated by adding union type of children states
update = (updater: (prevState:ICoreState) => ICoreState): void => {
console.log("before", this.state)
this.setState(updater(this.state));
console.log("after", this.state)
};
render() {
return (
<Panel
authPane = {this.state.panel.authPane}
displaying={this.state.panel.displaying}
update={this.update}
browser={this.state.panel.browser}
/>
);
}
}

View file

@ -0,0 +1,99 @@
@charset "utf-8";
.anm-rotate {
animation: rotate 1s infinite linear;
}
@keyframes rotate {
20% {
transform: rotate(72deg);
}
40% {
transform: rotate(144deg);
}
60% {
transform: rotate(216deg);
}
80% {
transform: rotate(288deg);
}
100% {
transform: rotate(360deg);
}
}
.fade-in {
/* animation-direction: reverse; */
animation: fade-in 0.2s 1 linear;
opacity: 0.5;
/* padding: 0; */
transform: translate(0, 0.4rem);
}
@keyframes fade-in {
0% {
opacity: 0.5;
transform: translate(0, 0.4rem);
/* padding: 0.4rem 0; */
}
100% {
opacity: 1;
transform: translate(0, 0);
/* transform: translate(0, 0.4rem); */
/* padding: 0; */
}
}
.notification-resize {
animation: resize 2s 1 linear;
/* transform: translate(100%, 0%); */
}
@keyframes resize {
0% {
/* transform: translate(0, 0); */
opacity: 0;
transform: scale(1, 0);
}
5% {
/* transform: translate(0, 8rem); */
opacity: 0.8;
transform: scale(1, 1);
}
95% {
/* transform: translate(0, 8rem); */
opacity: 0.8;
transform: scale(1, 1);
}
100% {
/* transform: translate(0, 0rem); */
opacity: 0;
transform: scale(1, 0);
}
}
@keyframes fade {
0% {
opacity: 1;
}
100% {
opacity: 0.8;
}
}
.notification-cool-down {
animation: cooldown 0.3s 1 linear;
}
@keyframes cooldown {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.opacity-trans {
transition: opacity 0.15s linear;
}

View file

@ -0,0 +1,375 @@
.blue0-font {
color: #3498db;
}
.blue1-font {
color: #2980b9;
}
.cyan0-font {
color: #1abc9c;
}
.cyan1-font {
color: #16a085;
}
.purple0-font {
color: #9b59b6;
}
.purple1-font {
color: #8e44ad;
}
.red0-font {
color: #e74c3c;
}
.red1-font {
color: #c0392b;
}
.yellow0-font {
color: #f1c40f;
}
.yellow1-font {
color: #f39c12;
}
.yellow2-font {
color: #e67e22;
}
.yellow3-font {
color: #d35400;
}
.green0-font {
color: #2ecc71;
}
.green1-font {
color: #27ae60;
}
.white-font {
color: #fff;
}
.white0-font {
color: #ecf0f1;
}
.white1-font {
color: #bdc3c7;
}
.grey0-font {
color: #95a5a6;
}
.grey1-font {
color: #7f8c8d;
}
.black-font {
color: #000;
}
.black0-font {
color: #34495e;
}
.black1-font {
color: #2c3e50;
}
.blue0-bg {
background-color: #3498db;
}
.blue1-bg {
background-color: #2980b9;
}
.cyan0-bg {
background-color: #1abc9c;
}
.cyan1-bg {
background-color: #16a085;
}
.purple0-bg {
background-color: #9b59b6;
}
.purple1-bg {
background-color: #8e44ad;
}
.red0-bg {
background-color: #e74c3c;
}
.red1-bg {
background-color: #c0392b;
}
.yellow0-bg {
background-color: #f1c40f;
}
.yellow1-bg {
background-color: #f39c12;
}
.yellow2-bg {
background-color: #e67e22;
}
.yellow3-bg {
background-color: #d35400;
}
.green0-bg {
background-color: #2ecc71;
}
.green1-bg {
background-color: #27ae60;
}
.white-bg {
background-color: #fff;
}
.white0-bg {
background-color: #ecf0f1;
}
.white1-bg {
background-color: #bdc3c7;
}
.grey0-bg {
background-color: #95a5a6;
}
.grey1-bg {
background-color: #7f8c8d;
}
.black-bg {
background-color: #000;
}
.black0-bg {
background-color: #34495e;
}
.black1-bg {
background-color: #2c3e50;
}
.padding-xs {
padding: 0.25rem;
}
.padding-s {
padding: 0.5rem;
}
.padding-m {
padding: 1rem;
}
.padding-l {
padding: 2rem;
}
.padding-xl {
padding: 4rem;
}
.padding-l-xs {
padding-left: 0.25rem;
}
.padding-l-s {
padding-left: 0.5rem;
}
.padding-l-m {
padding-left: 1rem;
}
.padding-l-l {
padding-left: 2rem;
}
.padding-l-xl {
padding-left: 4rem;
}
.padding-r-xs {
padding-right: 0.25rem;
}
.padding-r-s {
padding-right: 0.5rem;
}
.padding-r-m {
padding-right: 1rem;
}
.padding-r-l {
padding-right: 2rem;
}
.padding-r-xl {
padding-right: 4rem;
}
.padding-t-xs {
padding-top: 0.25rem;
}
.padding-t-s {
padding-top: 0.5rem;
}
.padding-t-m {
padding-top: 1rem;
}
.padding-t-l {
padding-top: 2rem;
}
.padding-t-xl {
padding-top: 4rem;
}
.padding-b-xs {
padding-bottom: 0.25rem;
}
.padding-b-s {
padding-bottom: 0.5rem;
}
.padding-b-m {
padding-bottom: 1rem;
}
.padding-b-l {
padding-bottom: 2rem;
}
.padding-b-xl {
padding-bottom: 4rem;
}
.margin-xs {
margin: 0.25rem;
}
.margin-s {
margin: 0.5rem;
}
.margin-m {
margin: 1rem;
}
.margin-l {
margin: 2rem;
}
.margin-xl {
margin: 4rem;
}
.margin-l-xs {
margin-left: 0.25rem;
}
.margin-l-s {
margin-left: 0.5rem;
}
.margin-l-m {
margin-left: 1rem;
}
.margin-l-l {
margin-left: 2rem;
}
.margin-l-xl {
margin-left: 4rem;
}
.margin-r-xs {
margin-right: 0.25rem;
}
.margin-r-s {
margin-right: 0.5rem;
}
.margin-r-m {
margin-right: 1rem;
}
.margin-r-l {
margin-right: 2rem;
}
.margin-r-xl {
margin-right: 4rem;
}
.margin-t-xs {
margin-top: 0.25rem;
}
.margin-t-s {
margin-top: 0.5rem;
}
.margin-t-m {
margin-top: 1rem;
}
.margin-t-l {
margin-top: 2rem;
}
.margin-t-xl {
margin-top: 4rem;
}
.margin-b-xs {
margin-bottom: 0.25rem;
}
.margin-b-s {
margin-bottom: 0.5rem;
}
.margin-b-m {
margin-bottom: 1rem;
}
.margin-b-l {
margin-bottom: 2rem;
}
.margin-b-xl {
margin-bottom: 4rem;
}

View file

@ -0,0 +1,24 @@
.desktop .font-xs {
font-size: 1.2rem;
line-height: 1.8rem;
}
.desktop .font-s {
font-size: 1.4rem;
line-height: 2.1rem;
}
.desktop .font-m {
font-size: 1.6rem;
line-height: 2.4rem;
}
.desktop .font-l {
font-size: 1.8rem;
line-height: 2.7rem;
}
.desktop .font-xl {
font-size: 2.0rem;
line-height: 3rem;
}

View file

@ -0,0 +1,118 @@
@charset "utf-8";
html,
body,
p,
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
outline: 0;
padding: 0;
border: 0;
}
html {
background-color: #ecf0f1;
/* background: url("bg.jpg") no-repeat left center fixed;
background-size: auto 100%; */
font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
font-size: 62.5%;
}
body {
line-height: 200%;
}
*:focus {
outline: none;
}
a,
a:link,
a:visited,
a:hover,
a:active,
button,
img {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently supported by Chrome and Opera */
}
a {
color: #16a085;
opacity: 100%;
text-decoration: none;
transition: color 1s 1 linear;
}
a:hover {
color: #2ecc71;
}
a:active {
}
a::selection {
background: transparent;
}
button {
background-color: transparent;
border: none;
outline: none;
}
button:active {
/* animation: fade 0.1s 1 linear; */
}
button:hover {
/* animation: fade 0.1s 1 linear; */
}
img {
max-width: 100%;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
text-align: left;
padding: 1rem 0;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
input {
font-size: 1.4rem;
line-height: 1.4rem;
height: 1.4rem;
font-weight: bold;
border: none;
padding: 0.8rem 1rem;
width: 10rem;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 0.5rem;
}
input:focus {
background-color: rgba(0, 0, 0, 0.2);
}
button {
border: none;
border-radius: 0.5rem;
padding: 0.8rem 1rem;
background-color: rgba(0, 0, 0, 0.3);
font-weight: bold;
border-radius: 0.5rem;
}

View file

@ -0,0 +1,486 @@
#bg {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow-y: scroll;
}
#top-bar {
line-height: 3rem;
}
#container-center {
margin: 5rem auto auto auto;
width: 96%;
max-width: 960px;
}
#op-bar {
width: 96%;
max-width: 960px;
position: fixed;
top: 6rem;
right: auto;
bottom: auto;
left: auto;
line-height: 3rem;
border-radius: 0.6rem;
}
#item-list {
margin-top: 12rem;
}
#item-list table {
width: 100%;
color: #34495e;
font-size: 1.4rem;
line-height: 4rem;
background-color: white;
border-radius: 0.8rem;
margin-bottom: 8rem;
}
#item-list tr {
border-top: solid 1px transparent;
}
#item-list .item-name {
display: block;
max-width: 8rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
#item-list tr:hover {
opacity: 0.8;
}
#panel {
position: fixed;
top: 4rem;
right: 0.5rem;
bottom: 6rem;
left: 0.5rem;
width: 80%;
margin: auto;
border-radius: 1.4rem;
background-color: white;
}
#panel-head {
text-align: left;
/* box-shadow: 0 0.2rem 3rem rgba(0, 0, 0, 0.1); */
padding: 1rem 2rem 1rem 2rem;
position: absolute;
top: 0;
right: 0;
bottom: auto;
left: 0;
font-size: 2.0rem;
line-height: 3.9rem;
}
#panel-head-menu {
text-align: right;
/* height: 3rem; */
position: absolute;
top: 4rem;
right: 0;
bottom: auto;
left: 0;
/* display: flex;
justify-content: space-between; */
padding: 1rem 2rem;
line-height: 4rem;
/* background-color: rgba(255, 255, 255, 0.6); */
}
.text-header {
font-size: 1.8rem;
font-weight: bold;
color: #16a085;
margin: auto;
line-height: 4rem;
}
#panel-body {
text-align: left;
overflow: hidden;
position: absolute;
top: 6rem;
right: 0;
bottom: 0.5rem;
left: 0;
/* box-shadow: 0 0.2rem 0.6rem rgba(0, 0, 0, 0.1); */
}
#nav-bar {
text-align: center;
display: flex;
justify-content: space-between;
padding: 0 0 1rem 0;
position: absolute;
top: auto;
right: 0;
bottom: 0;
left: 0;
height: 5rem;
box-shadow: 0 -0.2rem 3rem rgba(0, 0, 0, 0.1);
}
.nav-bar-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.win-left-bar {
text-align: right;
position: absolute;
top: 0.5rem;
/* right: auto; */
/* bottom: 0.5rem; */
left: -0.5rem;
/* box-shadow: 0 0.2rem 0.6rem rgba(0, 0, 0, 0.1); */
}
.win-right-bar {
text-align: left;
position: absolute;
top: 0.5rem;
/* right: auto; */
/* bottom: 0.5rem; */
right: -0.5rem;
/* box-shadow: 0 0.2rem 0.6rem rgba(0, 0, 0, 0.1); */
}
.right-bar {
font-size: 1.3rem;
line-height: 3rem;
width: 20rem;
position: absolute;
left: 2rem;
top: 4rem;
}
.link-list {
display: flex;
justify-content: space-between;
}
.ad-wrap {
padding: 0 0 1rem 0;
background-color: rgba(0, 0, 0, 0.1);
margin-bottom: 3rem;
text-align: center;
}
.ad-wrap-title {
line-height: 200%;
}
/* layoout */
.flex-2col-parent {
display: flex;
justify-content: space-between;
}
.flex-2col {
flex-grow: 0;
flex-shrink: 0;
flex-basis: 50%;
}
.flex-23col {
flex-grow: 0;
flex-shrink: 0;
flex-basis: 66%;
}
.flex-13col {
flex-grow: 0;
flex-shrink: 0;
flex-basis: 31%;
}
.flex-col-parent {
display: flex;
justify-content: space-between;
}
.flex-col {
flex-grow: 0;
flex-shrink: 0;
}
.clearfix {
clear: both;
}
.section {
text-align: left;
/* margin-bottom: 2rem; */
padding: 2rem;
}
.section-h {
text-align: left;
/* margin-bottom: 2rem; */
padding: 1rem 2rem;
}
.font-grey {
color: #999;
}
.margin-m {
margin: 0.5rem;
}
.margin-right-s {
margin-right: 0.25rem;
}
.margin-right-m {
margin-right: 0.5rem;
}
.margin-l {
margin: 1rem;
}
.margin-h-l {
margin: 1rem 0;
}
.margin-h-m {
margin: 0.8rem 0;
}
.margin-right-l {
margin-right: 1rem;
}
.margin-left-s {
margin-left: 0.25rem;
}
.margin-left-m {
margin-left: 0.5rem;
}
.margin-left-l {
margin-left: 1rem;
}
/* font */
.bold {
font-weight: bold;
}
.weight-normal {
font-weight: normal;
}
.h1,
.h2 {
font-size: 2rem;
font-weight: bold;
}
.h3,
.h4 {
font-size: 1.8rem;
font-weight: bold;
}
.h5,
.h6 {
font-size: 1.6rem;
font-weight: bold;
}
.noselect {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently supported by Chrome and Opera */
}
.text-left {
text-align: left;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
/* component */
.icon-s {
width: 1.4rem;
height: 1.4rem;
margin: -0.2rem 0.5rem auto 0.5rem;
vertical-align: middle;
}
.btn {
font-size: 1.4rem;
line-height: 2.4rem;
text-align: center;
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 2rem;
}
.pane {
font-size: 1.4rem;
text-align: left;
overflow-x: hidden;
overflow-y: scroll;
padding: 8rem 0 10rem 0;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.menu-container {
text-align: center;
margin: 2rem 0;
}
.menu {
background-color: rgba(0, 0, 0, 0.6);
font-size: 1.4rem;
line-height: 2rem;
text-align: center;
padding: 1rem 1rem;
display: inline-block;
border-radius: 4rem;
}
.wide-btn {
color: white;
text-align: center;
display: block;
padding: 1rem;
margin: 2rem 2rem 6rem 2rem;
border-radius: 2rem;
}
.border {
height: 1px;
opacity: 0.5;
}
.notification-container {
position: absolute;
top: 10rem;
right: 0;
bottom: auto;
left: 0;
max-height: 10rem;
overflow: hidden;
text-align: center;
margin: auto;
max-width: 48rem;
}
.notification {
/* color: white; */
background-color: rgba(0, 0, 0, 0.6);
/* font-weight: bold; */
font-size: 1.4rem;
line-height: 2rem;
text-align: center;
padding: 1rem 2rem;
display: inline-block;
border-radius: 1rem;
margin: 0.5rem 0.5rem;
}
.notification-ok {
color: white;
fill: #2ecc71;
}
.notification-error {
color: white;
fill: #e74c3c;
}
.notification-warn {
color: white;
fill: #f1c40f;
}
.font-size-m {
font-size: 1.4rem;
}
.news {
font-size: 1.2rem;
line-height: 4rem;
border-collapse: collapse;
}
.news .title {
font-size: 1.6rem;
line-height: 2.5rem;
display: inline-block;
margin-bottom: 1rem;
}
.news .desc {
font-size: 1.4rem;
line-height: 1.8rem;
}
.bottom-line {
padding: 3rem 0;
text-align: center;
color: #16a085;
}
select {
background: white;
border: transparent;
color: black;
font-size: 2rem;
}
.dot {
border-radius: 50%;
height: 0.8rem;
width: 0.8rem;
display: inline-block;
line-height: 3rem;
}
input.white-square {
border: solid 2px #fff;
background: transparent;
padding: 0.4rem 0.8rem;
color: #fff;
}
.grid {
display: inline-block;
padding: 1rem 1.5rem;
border-radius: 0.6rem;
line-height: 3rem;
}
.grid .title {
font-weight: bold;
}
.grid .desc {
font-size: 1.2rem;
color: #7f8c8d;
}
.grid-dot {
position: relative;
right: -1rem;
top: -1rem;
}
.hidden {
display: none;
}
div.hr {
height: 1px;
}
/*
.tag {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 0.4rem;
line-height: 1.6rem;
font-weight: normal;
font-size: 1.4rem;
}
.row {
text-align: left;
padding: 1rem 0;
} */

View file

@ -0,0 +1,69 @@
.theme-white .bg {
background-color: #ecf0f1;
}
.theme-white .text-color {
color: #34495e;
}
.theme-white .bg-img {
background: url("/static/img/textured_paper.png") repeat fixed center;
/* background: url("/static/img/huangpu.jpg") repeat fixed center; */
}
.theme-white .top-bar {
background: rgba( 255, 255, 255, 0.9 );
box-shadow: 0 5px 30px 0 rgba( 31, 38, 135, 0.1 );
backdrop-filter: blur( 9.5px );
-webkit-backdrop-filter: blur( 9.5px );
}
.theme-white div.hr {
background-color: white;
}
.theme-white .op-bar {
background: rgba( 255, 255, 255, 0.9 );
box-shadow: 0 5px 30px 0 rgba( 31, 38, 135, 0.1 );
backdrop-filter: blur( 9.5px );
-webkit-backdrop-filter: blur( 9.5px );
}
.theme-white .panel {
background-color: #ccc;
}
.theme-white .panel-head {
/* background-color: white; */
border-bottom: solid 1px #ecf0f1;
}
.theme-white .panel-head-menu {
background-color: white;
color: #34495e;
border-bottom: solid 1px #ecf0f1;
}
.theme-white .panel-body {
/* background-color: #fafafa; */
color: #34495e;
}
.theme-white .nav-bar {
background-color: rgba(255, 255, 255, 0.9);
color: #7f8c8d;
fill: black;
}
.theme-white .news .title {
color: #34495e;
}
.theme-white .border {
background-color: #e0e0e0;
}
.theme-white .wide-btn {
color: white;
background-color: #16a085;
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"outDir": "./dist/",
"sourceMap": true,
"noImplicitAny": true,
"module": "commonjs",
"target": "es5",
"jsx": "react",
"lib": ["es2015", "dom"]
},
"include": ["./src/**/*", "webpack.config.js", "webpack..js"],
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
}

View file

@ -0,0 +1,21 @@
const merge = require("webpack-merge");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const dev = require("./webpack.dev.js");
module.exports = merge(dev, {
entry: "./src/app.tsx",
context: `${__dirname}`,
output: {
path: `${__dirname}/../../../public/static`,
chunkFilename: "[name].bundle.js",
filename: "[name].bundle.js"
},
plugins: [
new HtmlWebpackPlugin({
template: `${__dirname}/build/template/index.template.dev.html`,
hash: true,
filename: `../index.html`
})
]
});

View file

@ -0,0 +1,21 @@
const merge = require("webpack-merge");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const prod = require("./webpack.prod.js");
module.exports = merge(prod, {
entry: "./src/app.tsx",
context: `${__dirname}`,
output: {
path: `${__dirname}/../../../public/static`,
chunkFilename: "[name].bundle.js",
filename: "[name].bundle.js"
},
plugins: [
new HtmlWebpackPlugin({
template: `${__dirname}/build/template/index.template.html`,
hash: true,
filename: `../index.html`
})
]
});

View file

@ -0,0 +1,63 @@
// const webpack = require("webpack");
// const CleanWebpackPlugin = require("clean-webpack-plugin");
// const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
const path = require("path");
const TerserPlugin = require("terser-webpack-plugin");
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
.BundleAnalyzerPlugin;
module.exports = {
module: {
rules: [
{
test: /\.ts|tsx$/,
loader: "ts-loader",
include: [path.resolve(__dirname, "src")],
exclude: /\.test\.(ts|tsx)$/
},
{
test: /\.css$/,
use: [
"style-loader",
{
loader: "css-loader",
options: {
url: false
}
}
]
}
]
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".json"]
},
plugins: [
// new BundleAnalyzerPlugin()
],
externals: {
react: "React",
"react-dom": "ReactDOM",
immutable: "Immutable"
},
optimization: {
minimizer: [new TerserPlugin()],
splitChunks: {
chunks: "all",
automaticNameDelimiter: ".",
cacheGroups: {
default: {
name: "main",
filename: "[name].bundle.js"
},
commons: {
test: /[\\/]node_modules[\\/]/,
name: "vendors",
chunks: "all",
minChunks: 2,
reuseExistingChunk: true
}
}
}
}
};

View file

@ -0,0 +1,16 @@
const merge = require("webpack-merge");
const common = require("./webpack.common.js");
module.exports = merge(common, {
mode: "development",
devtool: "inline-source-map",
// entry: {
// api_test: "./libs/test/api_test"
// },
watchOptions: {
aggregateTimeout: 1000,
poll: 1000,
ignored: /node_modules/
},
plugins: []
});

View file

@ -0,0 +1,8 @@
// const webpack = require("webpack");
const merge = require("webpack-merge");
const common = require("./webpack.common.js");
module.exports = merge(common, {
mode: "production"
});

5874
src/client/web/yarn.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -135,7 +135,7 @@ func (fs *LocalFS) Remove(entryPath string) error {
if err != nil {
return err
}
return os.Remove(fullpath)
return os.RemoveAll(fullpath)
}
func (fs *LocalFS) Rename(oldpath, newpath string) error {

View file

@ -2,6 +2,7 @@ package fileshdr
import (
"crypto/sha1"
"encoding/base64"
"errors"
"fmt"
"io"
@ -32,6 +33,8 @@ var (
rangeHeader = "Range"
acceptRangeHeader = "Accept-Range"
ifRangeHeader = "If-Range"
keepAliveHeader = "Keep-Alive"
connectionHeader = "Connection"
)
type FileHandlers struct {
@ -73,17 +76,22 @@ func (h *FileHandlers) NewAutoLocker(c *gin.Context, key string) *AutoLocker {
func (lk *AutoLocker) Exec(handler func()) {
var err error
kv := lk.h.deps.KV()
defer func() {
if p := recover(); p != nil {
fmt.Println(p)
}
if err = kv.Unlock(lk.key); err != nil {
fmt.Println(err)
}
}()
if err = kv.TryLock(lk.key); err != nil {
lk.c.JSON(q.Resp(500))
lk.c.JSON(q.ErrResp(lk.c, 500, errors.New("fail to lock the file")))
return
}
handler()
if err = kv.Unlock(lk.key); err != nil {
// TODO: use logger
fmt.Println(err)
}
}
type CreateReq struct {
@ -118,9 +126,9 @@ func (h *FileHandlers) Create(c *gin.Context) {
c.JSON(q.ErrResp(c, 500, err))
return
}
})
c.JSON(q.Resp(200))
})
}
func (h *FileHandlers) Delete(c *gin.Context) {
@ -255,12 +263,21 @@ func (h *FileHandlers) UploadChunk(c *gin.Context) {
return
}
wrote, err := h.deps.FS().WriteAt(tmpFilePath, []byte(req.Content), req.Offset)
content, err := base64.StdEncoding.DecodeString(req.Content)
if err != nil {
c.JSON(q.ErrResp(c, 500, err))
return
}
err = h.uploadMgr.IncreUploaded(tmpFilePath, int64(wrote))
fmt.Println("length", len([]byte(content)))
wrote, err := h.deps.FS().WriteAt(tmpFilePath, []byte(content), req.Offset)
if err != nil {
c.JSON(q.ErrResp(c, 500, err))
return
}
err = h.uploadMgr.SetUploaded(tmpFilePath, req.Offset+int64(wrote))
if err != nil {
c.JSON(q.ErrResp(c, 500, err))
return
@ -308,7 +325,11 @@ func (h *FileHandlers) UploadStatus(c *gin.Context) {
locker.Exec(func() {
_, fileSize, uploaded, err := h.uploadMgr.GetInfo(tmpFilePath)
if err != nil {
if os.IsNotExist(err) {
c.JSON(q.ErrResp(c, 404, err))
} else {
c.JSON(q.ErrResp(c, 500, err))
}
return
}
@ -337,7 +358,7 @@ func (h *FileHandlers) Download(c *gin.Context) {
info, err := h.deps.FS().Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
c.JSON(q.ErrResp(c, 400, os.ErrNotExist))
c.JSON(q.ErrResp(c, 404, os.ErrNotExist))
} else {
c.JSON(q.ErrResp(c, 500, err))
}

View file

@ -37,17 +37,13 @@ func (um *UploadMgr) AddInfo(fileName, tmpName string, fileSize int64, isDir boo
return um.kv.SetString(infoKey(tmpName, filePathKey), fileName)
}
func (um *UploadMgr) IncreUploaded(fileName string, newUploaded int64) error {
func (um *UploadMgr) SetUploaded(fileName string, newUploaded int64) error {
fileSize, ok := um.kv.GetInt64(infoKey(fileName, fileSizeKey))
if !ok {
return fmt.Errorf("file size %s not found", fileName)
}
preUploaded, ok := um.kv.GetInt64(infoKey(fileName, uploadedKey))
if !ok {
return fmt.Errorf("file uploaded %s not found", fileName)
}
if newUploaded+preUploaded <= fileSize {
um.kv.SetInt64(infoKey(fileName, uploadedKey), newUploaded+preUploaded)
if newUploaded <= fileSize {
um.kv.SetInt64(infoKey(fileName, uploadedKey), newUploaded)
return nil
}
return errors.New("uploaded is greater than file size")

View file

@ -89,6 +89,15 @@ type LoginReq struct {
Pwd string `json:"pwd"`
}
func (h *SimpleUserHandlers) checkPwd(user, pwd string) error {
expectedHash, ok := h.deps.KV().GetStringIn(UsersNs, user)
if !ok {
return ErrInvalidConfig
}
return bcrypt.CompareHashAndPassword([]byte(expectedHash), []byte(pwd))
}
func (h *SimpleUserHandlers) Login(c *gin.Context) {
req := &LoginReq{}
if err := c.ShouldBindJSON(&req); err != nil {
@ -96,15 +105,8 @@ func (h *SimpleUserHandlers) Login(c *gin.Context) {
return
}
expectedHash, ok := h.deps.KV().GetStringIn(UsersNs, req.User)
if !ok {
c.JSON(q.ErrResp(c, 500, ErrInvalidConfig))
return
}
err := bcrypt.CompareHashAndPassword([]byte(expectedHash), []byte(req.Pwd))
if err != nil {
c.JSON(q.ErrResp(c, 401, err))
if err := h.checkPwd(req.User, req.Pwd); err != nil {
c.JSON(q.ErrResp(c, 500, err))
return
}
@ -124,32 +126,30 @@ func (h *SimpleUserHandlers) Login(c *gin.Context) {
return
}
hostname := h.cfg.GrabString("Server.Host")
secure := h.cfg.GrabBool("Users.CookieSecure")
httpOnly := h.cfg.GrabBool("Users.CookieHttpOnly")
c.SetCookie(TokenCookie, token, ttl, "/", hostname, secure, httpOnly)
c.SetCookie(TokenCookie, token, ttl, "/", "", secure, httpOnly)
c.JSON(q.Resp(200))
}
type LogoutReq struct {
User string `json:"user"`
}
func (h *SimpleUserHandlers) Logout(c *gin.Context) {
req := &LogoutReq{}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(q.ErrResp(c, 500, err))
return
}
// token alreay verified in the authn middleware
c.SetCookie(TokenCookie, "", 0, "/", "nohost", false, true)
secure := h.cfg.GrabBool("Users.CookieSecure")
httpOnly := h.cfg.GrabBool("Users.CookieHttpOnly")
c.SetCookie(TokenCookie, "", 0, "/", "", secure, httpOnly)
c.JSON(q.Resp(200))
}
func (h *SimpleUserHandlers) IsAuthed(c *gin.Context) {
// token alreay verified in the authn middleware
c.JSON(q.Resp(200))
}
type SetPwdReq struct {
User string `json:"user"`
OldPwd string `json:"oldPwd"`
NewPwd string `json:"newPwd"`
}
@ -159,15 +159,24 @@ func (h *SimpleUserHandlers) SetPwd(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(q.ErrResp(c, 400, err))
return
} else if req.OldPwd == req.NewPwd {
c.JSON(q.ErrResp(c, 400, errors.New("password is not updated")))
return
}
expectedHash, ok := h.deps.KV().GetStringIn(UsersNs, req.User)
claims, err := h.getUserInfo(c)
if err != nil {
c.JSON(q.ErrResp(c, 401, err))
return
}
expectedHash, ok := h.deps.KV().GetStringIn(UsersNs, claims[UserParam])
if !ok {
c.JSON(q.ErrResp(c, 500, ErrInvalidConfig))
return
}
err := bcrypt.CompareHashAndPassword([]byte(expectedHash), []byte(req.OldPwd))
err = bcrypt.CompareHashAndPassword([]byte(expectedHash), []byte(req.OldPwd))
if err != nil {
c.JSON(q.ErrResp(c, 401, ErrInvalidUser))
return
@ -178,7 +187,7 @@ func (h *SimpleUserHandlers) SetPwd(c *gin.Context) {
c.JSON(q.ErrResp(c, 500, errors.New("fail to set password")))
return
}
err = h.deps.KV().SetStringIn(UsersNs, req.User, string(newHash))
err = h.deps.KV().SetStringIn(UsersNs, claims[UserParam], string(newHash))
if err != nil {
c.JSON(q.ErrResp(c, 500, ErrInvalidConfig))
return
@ -186,3 +195,25 @@ func (h *SimpleUserHandlers) SetPwd(c *gin.Context) {
c.JSON(q.Resp(200))
}
func (h *SimpleUserHandlers) getUserInfo(c *gin.Context) (map[string]string, error) {
tokenStr, err := c.Cookie(TokenCookie)
if err != nil {
return nil, err
}
claims, err := h.deps.Token().FromToken(
tokenStr,
map[string]string{
UserParam: "",
RoleParam: "",
ExpireParam: "",
},
)
if err != nil {
return nil, err
} else if claims[UserParam] == "" {
return nil, ErrInvalidConfig
}
return claims, nil
}

View file

@ -15,6 +15,13 @@ var exposedAPIs = map[string]bool{
"Health-fm": true,
}
var publicRootPath = "/"
var publicStaticPath = "/static"
func IsPublicPath(accessPath string) bool {
return accessPath == publicRootPath || strings.HasPrefix(accessPath, publicStaticPath)
}
func GetHandlerName(fullname string) (string, error) {
parts := strings.Split(fullname, ".")
if len(parts) == 0 {
@ -30,13 +37,13 @@ func (h *SimpleUserHandlers) Auth() gin.HandlerFunc {
c.JSON(q.ErrResp(c, 401, err))
return
}
accessPath := c.Request.URL.String()
// TODO: may also check the path
enableAuth := h.cfg.GrabBool("Users.EnableAuth")
if enableAuth && !exposedAPIs[handlerName] {
if enableAuth && !exposedAPIs[handlerName] && !IsPublicPath(accessPath) {
token, err := c.Cookie(TokenCookie)
if err != nil {
c.JSON(q.ErrResp(c, 401, err))
c.AbortWithStatusJSON(q.ErrResp(c, 401, err))
return
}
@ -48,20 +55,20 @@ func (h *SimpleUserHandlers) Auth() gin.HandlerFunc {
_, err = h.deps.Token().FromToken(token, claims)
if err != nil {
c.JSON(q.ErrResp(c, 401, err))
c.AbortWithStatusJSON(q.ErrResp(c, 401, err))
return
}
now := time.Now().Unix()
expire, err := strconv.ParseInt(claims[ExpireParam], 10, 64)
if err != nil || expire <= now {
c.JSON(q.ErrResp(c, 401, err))
c.AbortWithStatusJSON(q.ErrResp(c, 401, err))
return
}
// visitor is only allowed to download
if claims[RoleParam] != AdminRole && handlerName != "Download-fm" {
c.JSON(q.Resp(401))
c.AbortWithStatusJSON(q.ErrResp(c, 401, errors.New("not allowed")))
return
}
}

View file

@ -49,7 +49,7 @@ func DefaultConfig() (string, error) {
OpenTTL: 60, // 1 min
},
Users: &UsersCfg{
EnableAuth: false,
EnableAuth: true,
DefaultAdmin: "",
DefaultAdminPwd: "",
CookieTTL: 3600 * 24 * 7, // 1 week
@ -61,10 +61,10 @@ func DefaultConfig() (string, error) {
},
Server: &ServerCfg{
Debug: false,
Host: "127.0.0.1",
Host: "0.0.0.0",
Port: 8888,
ReadTimeout: 2000,
WriteTimeout: 2000,
WriteTimeout: 1000 * 3600 * 24, // 1 day
MaxHeaderBytes: 512,
},
}

View file

@ -124,7 +124,7 @@ func initHandlers(router *gin.Engine, cfg gocfg.ICfg, deps *depidx.Deps) (*gin.E
// middleware
router.Use(userHdrs.Auth())
// tmp static server
router.Use(static.Serve("/", static.LocalFile("../static", false)))
router.Use(static.Serve("/", static.LocalFile("../public", false)))
// handler
v1 := router.Group("/v1")
@ -132,6 +132,7 @@ func initHandlers(router *gin.Engine, cfg gocfg.ICfg, deps *depidx.Deps) (*gin.E
usersAPI := v1.Group("/users")
usersAPI.POST("/login", userHdrs.Login)
usersAPI.POST("/logout", userHdrs.Logout)
usersAPI.GET("/isauthed", userHdrs.IsAuthed)
usersAPI.PATCH("/pwd", userHdrs.SetPwd)
filesAPI := v1.Group("/fs")

View file

@ -2,6 +2,7 @@ package server
import (
"crypto/sha1"
"encoding/base64"
"fmt"
"math/rand"
"net/http"
@ -60,7 +61,8 @@ func TestFileHandlers(t *testing.T) {
return false
}
res, _, errs = cl.UploadChunk(filePath, content, 0)
base64Content := base64.StdEncoding.EncodeToString([]byte(content))
res, _, errs = cl.UploadChunk(filePath, base64Content, 0)
if len(errs) > 0 {
t.Error(errs)
return false
@ -172,7 +174,9 @@ func TestFileHandlers(t *testing.T) {
right = len(contentBytes)
}
res, _, errs = cl.UploadChunk(filePath, string(contentBytes[i:right]), int64(i))
chunk := contentBytes[i:right]
chunkBase64 := base64.StdEncoding.EncodeToString(chunk)
res, _, errs = cl.UploadChunk(filePath, chunkBase64, int64(i))
i = right
if len(errs) > 0 {
t.Fatal(errs)
@ -193,6 +197,11 @@ func TestFileHandlers(t *testing.T) {
}
}
err = fs.Sync()
if err != nil {
t.Fatal(err)
}
// check uploaded file
fsFilePath := filepath.Join(fileshdr.FsDir, filePath)
info, err = fs.Stat(fsFilePath)
@ -245,6 +254,11 @@ func TestFileHandlers(t *testing.T) {
assertUploadOK(t, filePath, content)
}
err = fs.Sync()
if err != nil {
t.Fatal(err)
}
_, lResp, errs := cl.List(dirPath)
if len(errs) > 0 {
t.Fatal(errs)
@ -292,6 +306,11 @@ func TestFileHandlers(t *testing.T) {
}
}
err = fs.Sync()
if err != nil {
t.Fatal(err)
}
_, lResp, errs := cl.List(dstDir)
if len(errs) > 0 {
t.Fatal(errs)

View file

@ -54,14 +54,14 @@ func TestSingleUserHandlers(t *testing.T) {
token := client.GetCookie(resp.Cookies(), su.TokenCookie)
resp, _, errs = suCl.SetPwd(adminName, adminPwd, adminNewPwd, token)
resp, _, errs = suCl.SetPwd(adminPwd, adminNewPwd, token)
if len(errs) > 0 {
t.Fatal(errs)
} else if resp.StatusCode != 200 {
t.Fatal(resp.StatusCode)
}
resp, _, errs = suCl.Logout(adminName, token)
resp, _, errs = suCl.Logout(token)
if len(errs) > 0 {
t.Fatal(errs)
} else if resp.StatusCode != 200 {

94
src/static/index.html Normal file
View file

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Quickshare</title>
<meta
name="viewport"
content="initial-scale=1.0, maximum-scale=1, minimum-scale=1, user-scalable=no,uc-fitscreen=yes"
/>
<meta class="chrome-color" name="theme-color" content="black" />
<script src="/static/js/react.development.js?v=16.8.6"></script>
<script src="/static/js/react-dom.development.js?v=16.8.6"></script>
<script src="/static/js/immutable.min.js?v=4.0.0-rc.12"></script>
<!-- <link
rel="apple-touch-icon"
sizes="57x57"
href="/static/fav/apple-icon-57x57.png"
/>
<link
rel="apple-touch-icon"
sizes="60x60"
href="/static/fav/apple-icon-60x60.png"
/>
<link
rel="apple-touch-icon"
sizes="72x72"
href="/static/fav/apple-icon-72x72.png"
/>
<link
rel="apple-touch-icon"
sizes="76x76"
href="/static/fav/apple-icon-76x76.png"
/>
<link
rel="apple-touch-icon"
sizes="114x114"
href="/static/fav/apple-icon-114x114.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
href="/static/fav/apple-icon-120x120.png"
/>
<link
rel="apple-touch-icon"
sizes="144x144"
href="/static/fav/apple-icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="152x152"
href="/static/fav/apple-icon-152x152.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/static/fav/apple-icon-180x180.png"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/static/fav/android-icon-192x192.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/static/fav/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/static/fav/favicon-96x96.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/static/fav/favicon-16x16.png"
/>
<link rel="manifest" href="/static/fav/manifest.json" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta
name="msapplication-TileImage"
content="/static/fav/ms-icon-144x144.png"
/> -->
<meta name="theme-color" content="#ffffff" />
</head>
<body>
<div id="content"><div id="mount"></div></div>
<script src="main.bundle.js?4fa055bbbe4e223c0472"></script></body>
</html>

View file

@ -2869,6 +2869,11 @@ filesize@^3.6.1:
resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317"
integrity sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg==
filesize@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/filesize/-/filesize-6.1.0.tgz#e81bdaa780e2451d714d71c0d7a4f3238d37ad00"
integrity sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg==
fill-range@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"