init monorepo
This commit is contained in:
parent
8c605af817
commit
ba867428e2
2
.gitignore
vendored
2
.gitignore
vendored
@ -12,6 +12,6 @@ db.sqlite3
|
||||
build/**
|
||||
app/**
|
||||
settings.json
|
||||
*config.json
|
||||
|
||||
.pnpm-store/**
|
||||
.env
|
143
app.ts
143
app.ts
@ -1,143 +0,0 @@
|
||||
import { app, BrowserWindow, dialog, session } from "electron";
|
||||
import { ipcMain } from "electron";
|
||||
import { join } from "path";
|
||||
import { accessTokenName, getAdminAccessTokenValue, getAdminRefreshTokenValue, refreshTokenName } from "./src/login";
|
||||
import { UserAccessor } from "./src/model/mod";
|
||||
import { create_server } from "./src/server";
|
||||
import { get_setting } from "./src/SettingConfig";
|
||||
|
||||
function registerChannel(cntr: UserAccessor) {
|
||||
ipcMain.handle("reset_password", async (event, username: string, password: string) => {
|
||||
const user = await cntr.findUser(username);
|
||||
if (user === undefined) {
|
||||
return false;
|
||||
}
|
||||
user.reset_password(password);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
const setting = get_setting();
|
||||
if (!setting.cli) {
|
||||
let wnd: BrowserWindow | null = null;
|
||||
|
||||
const createWindow = async () => {
|
||||
wnd = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
center: true,
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
},
|
||||
});
|
||||
await wnd.loadURL(`data:text/html;base64,` + Buffer.from(loading_html).toString("base64"));
|
||||
// await wnd.loadURL('../loading.html');
|
||||
// set admin cookies.
|
||||
await session.defaultSession.cookies.set({
|
||||
url: `http://localhost:${setting.port}`,
|
||||
name: accessTokenName,
|
||||
value: getAdminAccessTokenValue(),
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
});
|
||||
await session.defaultSession.cookies.set({
|
||||
url: `http://localhost:${setting.port}`,
|
||||
name: refreshTokenName,
|
||||
value: getAdminRefreshTokenValue(),
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
});
|
||||
try {
|
||||
const server = await create_server();
|
||||
const app = server.start_server();
|
||||
registerChannel(server.userController);
|
||||
await wnd.loadURL(`http://localhost:${setting.port}`);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
title: "error!",
|
||||
message: e.message,
|
||||
});
|
||||
} else {
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
title: "error!",
|
||||
message: String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
wnd.on("closed", () => {
|
||||
wnd = null;
|
||||
});
|
||||
};
|
||||
|
||||
const isPrimary = app.requestSingleInstanceLock();
|
||||
if (!isPrimary) {
|
||||
app.quit(); // exit window
|
||||
app.exit();
|
||||
}
|
||||
app.on("second-instance", () => {
|
||||
if (wnd != null) {
|
||||
if (wnd.isMinimized()) {
|
||||
wnd.restore();
|
||||
}
|
||||
wnd.focus();
|
||||
}
|
||||
});
|
||||
app.on("ready", (event, info) => {
|
||||
createWindow();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => { // quit when all windows are closed
|
||||
if (process.platform != "darwin") app.quit(); // (except leave MacOS app active until Cmd+Q)
|
||||
});
|
||||
|
||||
app.on("activate", () => { // re-recreate window when dock icon is clicked and no other windows open
|
||||
if (wnd == null) createWindow();
|
||||
});
|
||||
} else {
|
||||
(async () => {
|
||||
try {
|
||||
const server = await create_server();
|
||||
server.start_server();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
const loading_html = `<!DOCTYPE html>
|
||||
<html lang="ko"><head>
|
||||
<meta charset="UTF-8">
|
||||
<title>loading</title>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'
|
||||
fonts.googleapis.com; font-src 'self' fonts.gstatic.com">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<style>
|
||||
body { margin-top: 100px; background-color: #3f51b5; color: #fff; text-align:center; }
|
||||
h1 {
|
||||
font: 2em 'Roboto', sans-serif;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
#loading {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid rgba(255,255,255,.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg);}
|
||||
}
|
||||
</style>
|
||||
<body>
|
||||
<h1>Loading...</h1>
|
||||
<div id="loading"></div>
|
||||
</body>
|
||||
</html>`;
|
21
biome.jsonc
Normal file
21
biome.jsonc
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.6.3/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"lineWidth": 120
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
}
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
import { promises } from "fs";
|
||||
const { readdir, writeFile } = promises;
|
||||
import { dirname, join } from "path";
|
||||
import { createGenerator } from "ts-json-schema-generator";
|
||||
|
||||
async function genSchema(path: string, typename: string) {
|
||||
const gen = createGenerator({
|
||||
path: path,
|
||||
type: typename,
|
||||
tsconfig: "tsconfig.json",
|
||||
});
|
||||
const schema = gen.createSchema(typename);
|
||||
if (schema.definitions != undefined) {
|
||||
const definitions = schema.definitions;
|
||||
const definition = definitions[typename];
|
||||
if (typeof definition == "object") {
|
||||
let property = definition.properties;
|
||||
if (property) {
|
||||
property["$schema"] = {
|
||||
type: "string",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const text = JSON.stringify(schema);
|
||||
await writeFile(join(dirname(path), `${typename}.schema.json`), text);
|
||||
}
|
||||
function capitalize(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
async function setToALL(path: string) {
|
||||
console.log(`scan ${path}`);
|
||||
const direntry = await readdir(path, { withFileTypes: true });
|
||||
const works = direntry.filter(x => x.isFile() && x.name.endsWith("Config.ts")).map(x => {
|
||||
const name = x.name;
|
||||
const m = /(.+)\.ts/.exec(name);
|
||||
if (m !== null) {
|
||||
const typename = m[1];
|
||||
return genSchema(join(path, typename), capitalize(typename));
|
||||
}
|
||||
});
|
||||
await Promise.all(works);
|
||||
const subdir = direntry.filter(x => x.isDirectory()).map(x => x.name);
|
||||
for (const x of subdir) {
|
||||
await setToALL(join(path, x));
|
||||
}
|
||||
}
|
||||
setToALL("src");
|
@ -1,54 +0,0 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex) {
|
||||
await knex.schema.createTable("schema_migration", (b) => {
|
||||
b.string("version");
|
||||
b.boolean("dirty");
|
||||
});
|
||||
|
||||
await knex.schema.createTable("users", (b) => {
|
||||
b.string("username").primary().comment("user's login id");
|
||||
b.string("password_hash", 64).notNullable();
|
||||
b.string("password_salt", 64).notNullable();
|
||||
});
|
||||
await knex.schema.createTable("document", (b) => {
|
||||
b.increments("id").primary();
|
||||
b.string("title").notNullable();
|
||||
b.string("content_type", 16).notNullable();
|
||||
b.string("basepath", 256).notNullable().comment("directory path for resource");
|
||||
b.string("filename", 256).notNullable().comment("filename");
|
||||
b.string("content_hash").nullable();
|
||||
b.json("additional").nullable();
|
||||
b.integer("created_at").notNullable();
|
||||
b.integer("modified_at").notNullable();
|
||||
b.integer("deleted_at");
|
||||
b.index("content_type", "content_type_index");
|
||||
});
|
||||
await knex.schema.createTable("tags", (b) => {
|
||||
b.string("name").primary();
|
||||
b.text("description");
|
||||
});
|
||||
await knex.schema.createTable("doc_tag_relation", (b) => {
|
||||
b.integer("doc_id").unsigned().notNullable();
|
||||
b.string("tag_name").notNullable();
|
||||
b.foreign("doc_id").references("document.id");
|
||||
b.foreign("tag_name").references("tags.name");
|
||||
b.primary(["doc_id", "tag_name"]);
|
||||
});
|
||||
await knex.schema.createTable("permissions", b => {
|
||||
b.string("username").notNullable();
|
||||
b.string("name").notNullable();
|
||||
b.primary(["username", "name"]);
|
||||
b.foreign("username").references("users.username");
|
||||
});
|
||||
// create admin account.
|
||||
await knex.insert({
|
||||
username: "admin",
|
||||
password_hash: "unchecked",
|
||||
password_salt: "unchecked",
|
||||
}).into("users");
|
||||
}
|
||||
|
||||
export async function down(knex: Knex) {
|
||||
throw new Error("Downward migrations are not supported. Restore from backup.");
|
||||
}
|
86
package.json
86
package.json
@ -1,86 +1,20 @@
|
||||
{
|
||||
"name": "followed",
|
||||
"name": "ionian",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "build/app.js",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"compile": "tsc",
|
||||
"compile:watch": "tsc -w",
|
||||
"build": "cd src/client && pnpm run build:prod",
|
||||
"build:watch": "cd src/client && pnpm run build:watch",
|
||||
"fmt": "dprint fmt",
|
||||
"app": "electron build/app.js",
|
||||
"app:build": "electron-builder",
|
||||
"app:pack": "electron-builder --dir",
|
||||
"app:build:win64": "electron-builder --win --x64",
|
||||
"app:pack:win64": "electron-builder --win --x64 --dir",
|
||||
"cliapp": "node build/app.js"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"format": "biome format --write",
|
||||
"lint": "biome lint"
|
||||
},
|
||||
"build": {
|
||||
"asar": true,
|
||||
"files": [
|
||||
"build/**/*",
|
||||
"node_modules/**/*",
|
||||
"package.json"
|
||||
"keywords": [],
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "dist/",
|
||||
"to": "dist/",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!**/*.map"
|
||||
]
|
||||
},
|
||||
"index.html"
|
||||
],
|
||||
"appId": "com.prelude.ionian.app",
|
||||
"productName": "Ionian",
|
||||
"win": {
|
||||
"target": [
|
||||
"zip"
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"zip"
|
||||
]
|
||||
},
|
||||
"directories": {
|
||||
"output": "app/",
|
||||
"app": "."
|
||||
}
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@louislam/sqlite3": "^6.0.1",
|
||||
"@types/koa-compose": "^3.2.5",
|
||||
"chokidar": "^3.5.3",
|
||||
"dprint": "^0.36.1",
|
||||
"jsonschema": "^1.4.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"knex": "^0.95.15",
|
||||
"koa": "^2.13.4",
|
||||
"koa-bodyparser": "^4.3.0",
|
||||
"koa-compose": "^4.1.0",
|
||||
"koa-router": "^10.1.1",
|
||||
"natural-orderby": "^2.0.3",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"sqlite3": "^5.0.8",
|
||||
"tiny-async-pool": "^1.3.0"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/koa": "^2.13.4",
|
||||
"@types/koa-bodyparser": "^4.3.7",
|
||||
"@types/koa-router": "^7.4.4",
|
||||
"@types/node": "^14.18.21",
|
||||
"@types/tiny-async-pool": "^1.0.1",
|
||||
"electron": "^11.5.0",
|
||||
"electron-builder": "^22.14.13",
|
||||
"ts-json-schema-generator": "^0.82.0",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.7.4"
|
||||
"@biomejs/biome": "1.6.3"
|
||||
}
|
||||
}
|
18
packages/client/.eslintrc.cjs
Normal file
18
packages/client/.eslintrc.cjs
Normal file
@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
24
packages/client/.gitignore
vendored
Normal file
24
packages/client/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
30
packages/client/README.md
Normal file
30
packages/client/README.md
Normal file
@ -0,0 +1,30 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
13
packages/client/index.html
Normal file
13
packages/client/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
28
packages/client/package.json
Normal file
28
packages/client/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
1
packages/client/public/vite.svg
Normal file
1
packages/client/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
0
packages/client/src/App.css
Normal file
0
packages/client/src/App.css
Normal file
59
packages/client/src/App.tsx
Normal file
59
packages/client/src/App.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
|
||||
import './App.css'
|
||||
import React, { createContext, useEffect, useRef, useState } from "react";
|
||||
import ReactDom from "react-dom";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import {
|
||||
DifferencePage,
|
||||
DocumentAbout,
|
||||
Gallery,
|
||||
LoginPage,
|
||||
NotFoundPage,
|
||||
ProfilePage,
|
||||
ReaderPage,
|
||||
SettingPage,
|
||||
TagsPage,
|
||||
} from "./page/mod";
|
||||
import { getInitialValue, UserContext } from "./state";
|
||||
|
||||
import "./css/style.css";
|
||||
|
||||
const App = () => {
|
||||
const [user, setUser] = useState("");
|
||||
const [userPermission, setUserPermission] = useState<string[]>([]);
|
||||
(async () => {
|
||||
const { username, permission } = await getInitialValue();
|
||||
if (username !== user) {
|
||||
setUser(username);
|
||||
setUserPermission(permission);
|
||||
}
|
||||
})();
|
||||
// useEffect(()=>{});
|
||||
return (
|
||||
<UserContext.Provider
|
||||
value={{
|
||||
username: user,
|
||||
setUsername: setUser,
|
||||
permission: userPermission,
|
||||
setPermission: setUserPermission,
|
||||
}}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate replace to="/search?" />} />
|
||||
<Route path="/search" element={<Gallery />} />
|
||||
<Route path="/doc/:id" element={<DocumentAbout />}></Route>
|
||||
<Route path="/doc/:id/reader" element={<ReaderPage />}></Route>
|
||||
<Route path="/login" element={<LoginPage></LoginPage>} />
|
||||
<Route path="/profile" element={<ProfilePage />}></Route>
|
||||
<Route path="/difference" element={<DifferencePage />}></Route>
|
||||
<Route path="/setting" element={<SettingPage />}></Route>
|
||||
<Route path="/tags" element={<TagsPage />}></Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</UserContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App
|
99
packages/client/src/accessor/document.ts
Normal file
99
packages/client/src/accessor/document.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { Document, DocumentAccessor, DocumentBody, QueryListOption } from "../../model/doc";
|
||||
import { toQueryString } from "./util";
|
||||
const baseurl = "/api/doc";
|
||||
|
||||
export * from "../../model/doc";
|
||||
|
||||
export class FetchFailError extends Error {}
|
||||
|
||||
export class ClientDocumentAccessor implements DocumentAccessor {
|
||||
search: (search_word: string) => Promise<Document[]>;
|
||||
addList: (content_list: DocumentBody[]) => Promise<number[]>;
|
||||
async findByPath(basepath: string, filename?: string): Promise<Document[]> {
|
||||
throw new Error("not allowed");
|
||||
}
|
||||
async findDeleted(content_type: string): Promise<Document[]> {
|
||||
throw new Error("not allowed");
|
||||
}
|
||||
async findList(option?: QueryListOption | undefined): Promise<Document[]> {
|
||||
let res = await fetch(`${baseurl}/search?${option !== undefined ? toQueryString(option) : ""}`);
|
||||
if (res.status == 401) throw new FetchFailError("Unauthorized");
|
||||
if (res.status !== 200) throw new FetchFailError("findList Failed");
|
||||
let ret = await res.json();
|
||||
return ret;
|
||||
}
|
||||
async findById(id: number, tagload?: boolean | undefined): Promise<Document | undefined> {
|
||||
let res = await fetch(`${baseurl}/${id}`);
|
||||
if (res.status !== 200) throw new FetchFailError("findById Failed");
|
||||
let ret = await res.json();
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* not implement
|
||||
*/
|
||||
async findListByBasePath(basepath: string): Promise<Document[]> {
|
||||
throw new Error("not implement");
|
||||
return [];
|
||||
}
|
||||
async update(c: Partial<Document> & { id: number }): Promise<boolean> {
|
||||
const { id, ...rest } = c;
|
||||
const res = await fetch(`${baseurl}/${id}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(rest),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
const ret = await res.json();
|
||||
return ret;
|
||||
}
|
||||
async add(c: DocumentBody): Promise<number> {
|
||||
throw new Error("not allow");
|
||||
const res = await fetch(`${baseurl}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(c),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
const ret = await res.json();
|
||||
return ret;
|
||||
}
|
||||
async del(id: number): Promise<boolean> {
|
||||
const res = await fetch(`${baseurl}/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const ret = await res.json();
|
||||
return ret;
|
||||
}
|
||||
async addTag(c: Document, tag_name: string): Promise<boolean> {
|
||||
const { id, ...rest } = c;
|
||||
const res = await fetch(`${baseurl}/${id}/tags/${tag_name}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(rest),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
const ret = await res.json();
|
||||
return ret;
|
||||
}
|
||||
async delTag(c: Document, tag_name: string): Promise<boolean> {
|
||||
const { id, ...rest } = c;
|
||||
const res = await fetch(`${baseurl}/${id}/tags/${tag_name}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(rest),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
const ret = await res.json();
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
export const CDocumentAccessor = new ClientDocumentAccessor();
|
||||
export const makeThumbnailUrl = (x: Document) => {
|
||||
return `${baseurl}/${x.id}/${x.content_type}/thumbnail`;
|
||||
};
|
||||
|
||||
export default CDocumentAccessor;
|
28
packages/client/src/accessor/util.ts
Normal file
28
packages/client/src/accessor/util.ts
Normal file
@ -0,0 +1,28 @@
|
||||
type Representable = string | number | boolean;
|
||||
|
||||
type ToQueryStringA = {
|
||||
[name: string]: Representable | Representable[] | undefined;
|
||||
};
|
||||
|
||||
export const toQueryString = (obj: ToQueryStringA) => {
|
||||
return Object.entries(obj)
|
||||
.filter((e): e is [string, Representable | Representable[]] => e[1] !== undefined)
|
||||
.map((e) => (e[1] instanceof Array ? e[1].map((f) => `${e[0]}=${f}`).join("&") : `${e[0]}=${e[1]}`))
|
||||
.join("&");
|
||||
};
|
||||
export const QueryStringToMap = (query: string) => {
|
||||
const keyValue = query.slice(query.indexOf("?") + 1).split("&");
|
||||
const param: { [k: string]: string | string[] } = {};
|
||||
keyValue.forEach((p) => {
|
||||
const [k, v] = p.split("=");
|
||||
const pv = param[k];
|
||||
if (pv === undefined) {
|
||||
param[k] = v;
|
||||
} else if (typeof pv === "string") {
|
||||
param[k] = [pv, v];
|
||||
} else {
|
||||
pv.push(v);
|
||||
}
|
||||
});
|
||||
return param;
|
||||
};
|
1
packages/client/src/assets/react.svg
Normal file
1
packages/client/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
After Width: | Height: | Size: 4.0 KiB |
238
packages/client/src/component/contentinfo.tsx
Normal file
238
packages/client/src/component/contentinfo.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import React, {} from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { Document } from "../accessor/document";
|
||||
|
||||
import { Box, Button, Grid, Link, Paper, Theme, Typography, useTheme } from "@mui/material";
|
||||
import { TagChip } from "../component/tagchip";
|
||||
import { ThumbnailContainer } from "../page/reader/reader";
|
||||
|
||||
import DocumentAccessor from "../accessor/document";
|
||||
|
||||
export const makeContentInfoUrl = (id: number) => `/doc/${id}`;
|
||||
export const makeContentReaderUrl = (id: number) => `/doc/${id}/reader`;
|
||||
|
||||
const useStyles = (theme: Theme) => ({
|
||||
thumbnail_content: {
|
||||
maxHeight: "400px",
|
||||
maxWidth: "min(400px, 100vw)",
|
||||
},
|
||||
tag_list: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
flexWrap: "wrap",
|
||||
overflowY: "hidden",
|
||||
"& > *": {
|
||||
margin: theme.spacing(0.5),
|
||||
},
|
||||
},
|
||||
title: {
|
||||
marginLeft: theme.spacing(1),
|
||||
},
|
||||
infoContainer: {
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
subinfoContainer: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "100px auto",
|
||||
overflowY: "hidden",
|
||||
alignItems: "baseline",
|
||||
},
|
||||
short_subinfoContainer: {
|
||||
[theme.breakpoints.down("md")]: {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
short_root: {
|
||||
overflowY: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
[theme.breakpoints.up("sm")]: {
|
||||
height: 200,
|
||||
flexDirection: "row",
|
||||
},
|
||||
},
|
||||
short_thumbnail_anchor: {
|
||||
background: "#272733",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
[theme.breakpoints.up("sm")]: {
|
||||
width: theme.spacing(25),
|
||||
height: theme.spacing(25),
|
||||
flexShrink: 0,
|
||||
},
|
||||
},
|
||||
short_thumbnail_content: {
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
},
|
||||
});
|
||||
|
||||
export const ContentInfo = (props: {
|
||||
document: Document;
|
||||
children?: React.ReactNode;
|
||||
classes?: {
|
||||
root?: string;
|
||||
thumbnail_anchor?: string;
|
||||
thumbnail_content?: string;
|
||||
tag_list?: string;
|
||||
title?: string;
|
||||
infoContainer?: string;
|
||||
subinfoContainer?: string;
|
||||
};
|
||||
gallery?: string;
|
||||
short?: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const document = props.document;
|
||||
const url = props.gallery === undefined ? makeContentReaderUrl(document.id) : makeContentInfoUrl(document.id);
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
display: "flex",
|
||||
height: props.short ? "400px" : "auto",
|
||||
overflow: "hidden",
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
height: "auto",
|
||||
},
|
||||
}}
|
||||
elevation={4}
|
||||
>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={{
|
||||
pathname: makeContentReaderUrl(document.id),
|
||||
}}
|
||||
>
|
||||
{document.deleted_at === null ? (
|
||||
<ThumbnailContainer content={document} />
|
||||
) : (
|
||||
<Typography variant="h4">Deleted</Typography>
|
||||
)}
|
||||
</Link>
|
||||
<Box>
|
||||
<Link variant="h5" color="inherit" component={RouterLink} to={{ pathname: url }}>
|
||||
{document.title}
|
||||
</Link>
|
||||
<Box>
|
||||
{props.short ? (
|
||||
<Box>
|
||||
{document.tags.map((x) => (
|
||||
<TagChip key={x} label={x} clickable tagname={x} size="small"></TagChip>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<ComicDetailTag
|
||||
tags={document.tags}
|
||||
path={document.basepath + "/" + document.filename}
|
||||
createdAt={document.created_at}
|
||||
deletedAt={document.deleted_at != null ? document.deleted_at : undefined}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{document.deleted_at != null && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
documentDelete(document.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
async function documentDelete(id: number) {
|
||||
const t = await DocumentAccessor.del(id);
|
||||
if (t) {
|
||||
alert("document deleted!");
|
||||
} else {
|
||||
alert("document already deleted.");
|
||||
}
|
||||
}
|
||||
|
||||
function ComicDetailTag(prop: {
|
||||
tags: string[] /*classes:{
|
||||
tag_list:string
|
||||
}*/;
|
||||
path?: string;
|
||||
createdAt?: number;
|
||||
deletedAt?: number;
|
||||
}) {
|
||||
let allTag = prop.tags;
|
||||
const tagKind = ["artist", "group", "series", "type", "character"];
|
||||
let tagTable: { [kind: string]: string[] } = {};
|
||||
for (const kind of tagKind) {
|
||||
const tags = allTag.filter((x) => x.startsWith(kind + ":")).map((x) => x.slice(kind.length + 1));
|
||||
tagTable[kind] = tags;
|
||||
allTag = allTag.filter((x) => !x.startsWith(kind + ":"));
|
||||
}
|
||||
return (
|
||||
<Grid container>
|
||||
{tagKind.map((key) => (
|
||||
<React.Fragment key={key}>
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="subtitle1">{key}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={9}>
|
||||
<Box>
|
||||
{tagTable[key].length !== 0
|
||||
? tagTable[key].map((elem, i) => {
|
||||
return (
|
||||
<>
|
||||
<Link to={`/search?allow_tag=${key}:${encodeURIComponent(elem)}`} component={RouterLink}>
|
||||
{elem}
|
||||
</Link>
|
||||
{i < tagTable[key].length - 1 ? "," : ""}
|
||||
</>
|
||||
);
|
||||
})
|
||||
: "N/A"}
|
||||
</Box>
|
||||
</Grid>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{prop.path != undefined && (
|
||||
<>
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="subtitle1">Path</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={9}>
|
||||
<Box>{prop.path}</Box>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
{prop.createdAt != undefined && (
|
||||
<>
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="subtitle1">CreatedAt</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={9}>
|
||||
<Box>{new Date(prop.createdAt).toUTCString()}</Box>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
{prop.deletedAt != undefined && (
|
||||
<>
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="subtitle1">DeletedAt</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={9}>
|
||||
<Box>{new Date(prop.deletedAt).toUTCString()}</Box>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="subtitle1">Tags</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={9}>
|
||||
{allTag.map((x) => (
|
||||
<TagChip key={x} label={x} clickable tagname={x} size="small"></TagChip>
|
||||
))}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
273
packages/client/src/component/headline.tsx
Normal file
273
packages/client/src/component/headline.tsx
Normal file
@ -0,0 +1,273 @@
|
||||
import { AccountCircle, ChevronLeft, ChevronRight, Menu as MenuIcon, Search as SearchIcon } from "@mui/icons-material";
|
||||
import {
|
||||
AppBar,
|
||||
Button,
|
||||
CssBaseline,
|
||||
Divider,
|
||||
Drawer,
|
||||
Hidden,
|
||||
IconButton,
|
||||
InputBase,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
MenuItem,
|
||||
styled,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { alpha, Theme, useTheme } from "@mui/material/styles";
|
||||
import React, { useContext, useState } from "react";
|
||||
|
||||
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||
import { doLogout, UserContext } from "../state";
|
||||
|
||||
const drawerWidth = 270;
|
||||
|
||||
const DrawerHeader = styled("div")(({ theme }) => ({
|
||||
...theme.mixins.toolbar,
|
||||
}));
|
||||
|
||||
const StyledDrawer = styled(Drawer)(({ theme }) => ({
|
||||
flexShrink: 0,
|
||||
whiteSpace: "nowrap",
|
||||
[theme.breakpoints.up("sm")]: {
|
||||
width: drawerWidth,
|
||||
},
|
||||
}));
|
||||
const StyledSearchBar = styled("div")(({ theme }) => ({
|
||||
position: "relative",
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: alpha(theme.palette.common.white, 0.15),
|
||||
"&:hover": {
|
||||
backgroundColor: alpha(theme.palette.common.white, 0.25),
|
||||
},
|
||||
marginLeft: 0,
|
||||
width: "100%",
|
||||
[theme.breakpoints.up("sm")]: {
|
||||
marginLeft: theme.spacing(1),
|
||||
width: "auto",
|
||||
},
|
||||
}));
|
||||
const StyledInputBase = styled(InputBase)(({ theme }) => ({
|
||||
color: "inherit",
|
||||
"& .MuiInputBase-input": {
|
||||
padding: theme.spacing(1, 1, 1, 0),
|
||||
// vertical padding + font size from searchIcon
|
||||
paddingLeft: `calc(1em + ${theme.spacing(4)})`,
|
||||
transition: theme.transitions.create("width"),
|
||||
width: "100%",
|
||||
[theme.breakpoints.up("sm")]: {
|
||||
width: "12ch",
|
||||
"&:focus": {
|
||||
width: "20ch",
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledNav = styled("nav")(({ theme }) => ({
|
||||
[theme.breakpoints.up("sm")]: {
|
||||
width: theme.spacing(7),
|
||||
},
|
||||
}));
|
||||
|
||||
const closedMixin = (theme: Theme) => ({
|
||||
overflowX: "hidden",
|
||||
width: `calc(${theme.spacing(7)} + 1px)`,
|
||||
});
|
||||
|
||||
export const Headline = (prop: {
|
||||
children?: React.ReactNode;
|
||||
classes?: {
|
||||
content?: string;
|
||||
toolbar?: string;
|
||||
};
|
||||
rightAppbar?: React.ReactNode;
|
||||
menu: React.ReactNode;
|
||||
}) => {
|
||||
const [v, setv] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const theme = useTheme();
|
||||
const toggleV = () => setv(!v);
|
||||
const handleProfileMenuOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget);
|
||||
const handleProfileMenuClose = () => setAnchorEl(null);
|
||||
const isProfileMenuOpened = Boolean(anchorEl);
|
||||
const menuId = "primary-search-account-menu";
|
||||
const user_ctx = useContext(UserContext);
|
||||
const isLogin = user_ctx.username !== "";
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const renderProfileMenu = (
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{ horizontal: "right", vertical: "top" }}
|
||||
id={menuId}
|
||||
open={isProfileMenuOpened}
|
||||
keepMounted
|
||||
transformOrigin={{ horizontal: "right", vertical: "top" }}
|
||||
onClose={handleProfileMenuClose}
|
||||
>
|
||||
<MenuItem component={RouterLink} to="/profile">
|
||||
Profile
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={async () => {
|
||||
handleProfileMenuClose();
|
||||
await doLogout();
|
||||
user_ctx.setUsername("");
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
const drawer_contents = (
|
||||
<>
|
||||
<DrawerHeader>
|
||||
<IconButton onClick={toggleV}>{theme.direction === "ltr" ? <ChevronLeft /> : <ChevronRight />}</IconButton>
|
||||
</DrawerHeader>
|
||||
<Divider />
|
||||
{prop.menu}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex" }}>
|
||||
<CssBaseline />
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
transition: theme.transitions.create(["width", "margin"], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
onClick={toggleV}
|
||||
edge="start"
|
||||
style={{ marginRight: 36 }}
|
||||
>
|
||||
<MenuIcon></MenuIcon>
|
||||
</IconButton>
|
||||
<Link
|
||||
variant="h5"
|
||||
noWrap
|
||||
sx={{
|
||||
display: "none",
|
||||
[theme.breakpoints.up("sm")]: {
|
||||
display: "block",
|
||||
},
|
||||
}}
|
||||
color="inherit"
|
||||
component={RouterLink}
|
||||
to="/"
|
||||
>
|
||||
Ionian
|
||||
</Link>
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
{prop.rightAppbar}
|
||||
<StyledSearchBar>
|
||||
<div
|
||||
style={{
|
||||
padding: theme.spacing(0, 2),
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<SearchIcon onClick={() => navSearch(search)} />
|
||||
</div>
|
||||
<StyledInputBase
|
||||
placeholder="search"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
navSearch(search);
|
||||
}
|
||||
}}
|
||||
value={search}
|
||||
/>
|
||||
</StyledSearchBar>
|
||||
{isLogin ? (
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="account of current user"
|
||||
aria-controls={menuId}
|
||||
aria-haspopup="true"
|
||||
onClick={handleProfileMenuOpen}
|
||||
color="inherit"
|
||||
>
|
||||
<AccountCircle />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button color="inherit" component={RouterLink} to="/login">
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
{renderProfileMenu}
|
||||
<StyledNav>
|
||||
<Hidden smUp implementation="css">
|
||||
<StyledDrawer
|
||||
variant="temporary"
|
||||
anchor="left"
|
||||
open={v}
|
||||
onClose={toggleV}
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
}}
|
||||
>
|
||||
{drawer_contents}
|
||||
</StyledDrawer>
|
||||
</Hidden>
|
||||
<Hidden smDown implementation="css">
|
||||
<StyledDrawer
|
||||
variant="permanent"
|
||||
anchor="left"
|
||||
sx={{
|
||||
...closedMixin(theme),
|
||||
"& .MuiDrawer-paper": closedMixin(theme),
|
||||
}}
|
||||
>
|
||||
{drawer_contents}
|
||||
</StyledDrawer>
|
||||
</Hidden>
|
||||
</StyledNav>
|
||||
<main
|
||||
style={{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
flexGrow: 1,
|
||||
padding: "0px",
|
||||
marginTop: "64px",
|
||||
}}
|
||||
>
|
||||
{prop.children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
function navSearch(search: string) {
|
||||
let words = search.includes("&") ? search.split("&") : [search];
|
||||
words = words
|
||||
.map((w) => w.trim())
|
||||
.map((w) => (w.includes(":") ? `allow_tag=${w}` : `word=${encodeURIComponent(w)}`));
|
||||
navigate(`/search?${words.join("&")}`);
|
||||
}
|
||||
};
|
||||
|
||||
export default Headline;
|
10
packages/client/src/component/loading.tsx
Normal file
10
packages/client/src/component/loading.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
import React from "react";
|
||||
|
||||
export const LoadingCircle = () => {
|
||||
return (
|
||||
<Box style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%,-50%)" }}>
|
||||
<CircularProgress title="loading" />
|
||||
</Box>
|
||||
);
|
||||
};
|
54
packages/client/src/component/navlist.tsx
Normal file
54
packages/client/src/component/navlist.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import {
|
||||
ArrowBack as ArrowBackIcon,
|
||||
Collections as CollectionIcon,
|
||||
Folder as FolderIcon,
|
||||
Home as HomeIcon,
|
||||
List as ListIcon,
|
||||
Settings as SettingIcon,
|
||||
VideoLibrary as VideoIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Divider, List, ListItem, ListItemIcon, ListItemText, Tooltip } from "@mui/material";
|
||||
import React from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
export const NavItem = (props: { name: string; to: string; icon: React.ReactElement<any, any> }) => {
|
||||
return (
|
||||
<ListItem button key={props.name} component={RouterLink} to={props.to}>
|
||||
<ListItemIcon>
|
||||
<Tooltip title={props.name.toLocaleLowerCase()} placement="bottom">
|
||||
{props.icon}
|
||||
</Tooltip>
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={props.name}></ListItemText>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const NavList = (props: { children?: React.ReactNode }) => {
|
||||
return <List>{props.children}</List>;
|
||||
};
|
||||
|
||||
export const BackItem = (props: { to?: string }) => {
|
||||
return <NavItem name="Back" to={props.to ?? "/"} icon={<ArrowBackIcon></ArrowBackIcon>} />;
|
||||
};
|
||||
|
||||
export function CommonMenuList(props?: { url?: string }) {
|
||||
let url = props?.url ?? "";
|
||||
return (
|
||||
<NavList>
|
||||
{url !== "" && (
|
||||
<>
|
||||
<BackItem to={url} /> <Divider />
|
||||
</>
|
||||
)}
|
||||
<NavItem name="All" to="/" icon={<HomeIcon />} />
|
||||
<NavItem name="Comic" to="/search?content_type=comic" icon={<CollectionIcon />}></NavItem>
|
||||
<NavItem name="Video" to="/search?content_type=video" icon={<VideoIcon />} />
|
||||
<Divider />
|
||||
<NavItem name="Tags" to="/tags" icon={<ListIcon />} />
|
||||
<Divider />
|
||||
<NavItem name="Difference" to="/difference" icon={<FolderIcon />}></NavItem>
|
||||
<NavItem name="Settings" to="/setting" icon={<SettingIcon />} />
|
||||
</NavList>
|
||||
);
|
||||
}
|
5
packages/client/src/component/pagepad.tsx
Normal file
5
packages/client/src/component/pagepad.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { styled } from "@mui/material";
|
||||
|
||||
export const PagePad = styled("div")(({ theme }) => ({
|
||||
padding: theme.spacing(3),
|
||||
}));
|
80
packages/client/src/component/tagchip.tsx
Normal file
80
packages/client/src/component/tagchip.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import * as colors from "@mui/material/colors";
|
||||
import Chip, { ChipTypeMap } from "@mui/material/Chip";
|
||||
import { emphasize, styled, Theme, useTheme } from "@mui/material/styles";
|
||||
import React from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
type TagChipStyleProp = {
|
||||
color: `rgba(${number},${number},${number},${number})` | `#${string}` | "default";
|
||||
};
|
||||
|
||||
const { blue, pink } = colors;
|
||||
const getTagColorName = (tagname: string): TagChipStyleProp["color"] => {
|
||||
if (tagname.startsWith("female")) {
|
||||
return pink[600];
|
||||
} else if (tagname.startsWith("male")) {
|
||||
return blue[600];
|
||||
} else return "default";
|
||||
};
|
||||
|
||||
type ColorChipProp = Omit<ChipTypeMap["props"], "color"> &
|
||||
TagChipStyleProp & {
|
||||
component?: React.ElementType;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export const ColorChip = (props: ColorChipProp) => {
|
||||
const { color, ...rest } = props;
|
||||
const theme = useTheme();
|
||||
|
||||
let newcolor = color;
|
||||
if (color === "default") {
|
||||
newcolor = "#ebebeb";
|
||||
}
|
||||
return (
|
||||
<Chip
|
||||
sx={{
|
||||
color: theme.palette.getContrastText(newcolor),
|
||||
backgroundColor: newcolor,
|
||||
["&:hover, &:focus"]: {
|
||||
backgroundColor: emphasize(newcolor, 0.08),
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
></Chip>
|
||||
);
|
||||
};
|
||||
|
||||
type TagChipProp = Omit<ChipTypeMap["props"], "color"> & {
|
||||
tagname: string;
|
||||
};
|
||||
|
||||
export const TagChip = (props: TagChipProp) => {
|
||||
const { tagname, label, clickable, ...rest } = props;
|
||||
const colorName = getTagColorName(tagname);
|
||||
|
||||
let newlabel: React.ReactNode = label;
|
||||
if (typeof label === "string") {
|
||||
const female = "female:";
|
||||
const male = "male:";
|
||||
if (label.startsWith(female)) {
|
||||
newlabel = "♀ " + label.slice(female.length);
|
||||
} else if (label.startsWith(male)) {
|
||||
newlabel = "♂ " + label.slice(male.length);
|
||||
}
|
||||
}
|
||||
|
||||
const inner = clickable ? (
|
||||
<ColorChip
|
||||
color={colorName}
|
||||
clickable={clickable}
|
||||
label={newlabel ?? label}
|
||||
{...rest}
|
||||
component={RouterLink}
|
||||
to={`/search?allow_tag=${tagname}`}
|
||||
/>
|
||||
) : (
|
||||
<ColorChip color={colorName} clickable={clickable} label={newlabel ?? label} {...rest} />
|
||||
);
|
||||
return inner;
|
||||
};
|
0
packages/client/src/index.css
Normal file
0
packages/client/src/index.css
Normal file
10
packages/client/src/main.tsx
Normal file
10
packages/client/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
136
packages/client/src/page/contentinfo.tsx
Normal file
136
packages/client/src/page/contentinfo.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { IconButton, Theme, Typography } from "@mui/material";
|
||||
import FullscreenIcon from "@mui/icons-material/Fullscreen";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Route, Routes, useLocation, useParams } from "react-router-dom";
|
||||
import DocumentAccessor, { Document } from "../accessor/document";
|
||||
import { LoadingCircle } from "../component/loading";
|
||||
import { CommonMenuList, ContentInfo, Headline } from "../component/mod";
|
||||
import { NotFoundPage } from "./404";
|
||||
import { getPresenter } from "./reader/reader";
|
||||
import { PagePad } from "../component/pagepad";
|
||||
|
||||
export const makeContentInfoUrl = (id: number) => `/doc/${id}`;
|
||||
export const makeComicReaderUrl = (id: number) => `/doc/${id}/reader`;
|
||||
|
||||
type DocumentState = {
|
||||
doc: Document | undefined;
|
||||
notfound: boolean;
|
||||
};
|
||||
|
||||
export function ReaderPage(props?: {}) {
|
||||
const location = useLocation();
|
||||
const match = useParams<{ id: string }>();
|
||||
if (match == null) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
const id = Number.parseInt(match.id ?? "NaN");
|
||||
const [info, setInfo] = useState<DocumentState>({ doc: undefined, notfound: false });
|
||||
const menu_list = (link?: string) => <CommonMenuList url={link}></CommonMenuList>;
|
||||
const fullScreenTargetRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!isNaN(id)) {
|
||||
const c = await DocumentAccessor.findById(id);
|
||||
setInfo({ doc: c, notfound: c === undefined });
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return (
|
||||
<Headline menu={menu_list()}>
|
||||
<Typography variant="h2">Oops. Invalid ID</Typography>
|
||||
</Headline>
|
||||
);
|
||||
} else if (info.notfound) {
|
||||
return (
|
||||
<Headline menu={menu_list()}>
|
||||
<Typography variant="h2">Content has been removed.</Typography>
|
||||
</Headline>
|
||||
);
|
||||
} else if (info.doc === undefined) {
|
||||
return (
|
||||
<Headline menu={menu_list()}>
|
||||
<LoadingCircle />
|
||||
</Headline>
|
||||
);
|
||||
} else {
|
||||
const ReaderPage = getPresenter(info.doc);
|
||||
return (
|
||||
<Headline
|
||||
menu={menu_list(location.pathname)}
|
||||
rightAppbar={
|
||||
<IconButton
|
||||
edge="start"
|
||||
aria-label="account of current user"
|
||||
aria-haspopup="true"
|
||||
onClick={() => {
|
||||
if (fullScreenTargetRef.current != null && document.fullscreenEnabled) {
|
||||
fullScreenTargetRef.current.requestFullscreen();
|
||||
}
|
||||
}}
|
||||
color="inherit"
|
||||
>
|
||||
<FullscreenIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ReaderPage doc={info.doc} fullScreenTarget={fullScreenTargetRef}></ReaderPage>
|
||||
</Headline>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const DocumentAbout = (prop?: {}) => {
|
||||
const match = useParams<{ id: string }>();
|
||||
if (match == null) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
const id = Number.parseInt(match.id ?? "NaN");
|
||||
const [info, setInfo] = useState<DocumentState>({ doc: undefined, notfound: false });
|
||||
const menu_list = (link?: string) => <CommonMenuList url={link}></CommonMenuList>;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!isNaN(id)) {
|
||||
const c = await DocumentAccessor.findById(id);
|
||||
setInfo({ doc: c, notfound: c === undefined });
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return (
|
||||
<Headline menu={menu_list()}>
|
||||
<PagePad>
|
||||
<Typography variant="h2">Oops. Invalid ID</Typography>
|
||||
</PagePad>
|
||||
</Headline>
|
||||
);
|
||||
} else if (info.notfound) {
|
||||
return (
|
||||
<Headline menu={menu_list()}>
|
||||
<PagePad>
|
||||
<Typography variant="h2">Content has been removed.</Typography>
|
||||
</PagePad>
|
||||
</Headline>
|
||||
);
|
||||
} else if (info.doc === undefined) {
|
||||
return (
|
||||
<Headline menu={menu_list()}>
|
||||
<PagePad>
|
||||
<LoadingCircle />
|
||||
</PagePad>
|
||||
</Headline>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Headline menu={menu_list()}>
|
||||
<PagePad>
|
||||
<ContentInfo document={info.doc}></ContentInfo>
|
||||
</PagePad>
|
||||
</Headline>
|
||||
);
|
||||
}
|
||||
};
|
126
packages/client/src/page/difference.tsx
Normal file
126
packages/client/src/page/difference.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { Box, Button, Paper, Typography } from "@mui/material";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { CommonMenuList, Headline } from "../component/mod";
|
||||
import { UserContext } from "../state";
|
||||
import { PagePad } from "../component/pagepad";
|
||||
|
||||
type FileDifference = {
|
||||
type: string;
|
||||
value: {
|
||||
type: string;
|
||||
path: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
function TypeDifference(prop: {
|
||||
content: FileDifference;
|
||||
onCommit: (v: { type: string; path: string }) => void;
|
||||
onCommitAll: (type: string) => void;
|
||||
}) {
|
||||
// const classes = useStyles();
|
||||
const x = prop.content;
|
||||
const [button_disable, set_disable] = useState(false);
|
||||
|
||||
return (
|
||||
<Paper /*className={classes.paper}*/>
|
||||
<Box /*className={classes.contentTitle}*/>
|
||||
<Typography variant="h3">{x.type}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
key={x.type}
|
||||
onClick={() => {
|
||||
set_disable(true);
|
||||
prop.onCommitAll(x.type);
|
||||
set_disable(false);
|
||||
}}
|
||||
>
|
||||
Commit all
|
||||
</Button>
|
||||
</Box>
|
||||
{x.value.map((y) => (
|
||||
<Box sx={{ display: "flex" }} key={y.path}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
set_disable(true);
|
||||
prop.onCommit(y);
|
||||
set_disable(false);
|
||||
}}
|
||||
disabled={button_disable}
|
||||
>
|
||||
Commit
|
||||
</Button>
|
||||
<Typography variant="h5">{y.path}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function DifferencePage() {
|
||||
const ctx = useContext(UserContext);
|
||||
// const classes = useStyles();
|
||||
const [diffList, setDiffList] = useState<FileDifference[]>([]);
|
||||
const doLoad = async () => {
|
||||
const list = await fetch("/api/diff/list");
|
||||
if (list.ok) {
|
||||
const inner = await list.json();
|
||||
setDiffList(inner);
|
||||
} else {
|
||||
// setDiffList([]);
|
||||
}
|
||||
};
|
||||
const Commit = async (x: { type: string; path: string }) => {
|
||||
const res = await fetch("/api/diff/commit", {
|
||||
method: "POST",
|
||||
body: JSON.stringify([{ ...x }]),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
const bb = await res.json();
|
||||
if (bb.ok) {
|
||||
doLoad();
|
||||
} else {
|
||||
console.error("fail to add document");
|
||||
}
|
||||
};
|
||||
const CommitAll = async (type: string) => {
|
||||
const res = await fetch("/api/diff/commitall", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ type: type }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
const bb = await res.json();
|
||||
if (bb.ok) {
|
||||
doLoad();
|
||||
} else {
|
||||
console.error("fail to add document");
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
doLoad();
|
||||
const i = setInterval(doLoad, 5000);
|
||||
return () => {
|
||||
clearInterval(i);
|
||||
};
|
||||
}, []);
|
||||
const menu = CommonMenuList();
|
||||
return (
|
||||
<Headline menu={menu}>
|
||||
<PagePad>
|
||||
{ctx.username == "admin" ? (
|
||||
<div>
|
||||
{diffList.map((x) => (
|
||||
<TypeDifference key={x.type} content={x} onCommit={Commit} onCommitAll={CommitAll} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Typography variant="h2">Not Allowed : please login as an admin</Typography>
|
||||
)}
|
||||
</PagePad>
|
||||
</Headline>
|
||||
);
|
||||
}
|
133
packages/client/src/page/gallery.tsx
Normal file
133
packages/client/src/page/gallery.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { CommonMenuList, ContentInfo, Headline, LoadingCircle, NavItem, NavList, TagChip } from "../component/mod";
|
||||
|
||||
import { Box, Button, Chip, Pagination, Typography } from "@mui/material";
|
||||
import ContentAccessor, { Document, QueryListOption } from "../accessor/document";
|
||||
import { toQueryString } from "../accessor/util";
|
||||
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { QueryStringToMap } from "../accessor/util";
|
||||
import { useIsElementInViewport } from "./reader/reader";
|
||||
import { PagePad } from "../component/pagepad";
|
||||
|
||||
export type GalleryProp = {
|
||||
option?: QueryListOption;
|
||||
diff: string;
|
||||
};
|
||||
type GalleryState = {
|
||||
documents: Document[] | undefined;
|
||||
};
|
||||
|
||||
export const GalleryInfo = (props: GalleryProp) => {
|
||||
const [state, setState] = useState<GalleryState>({ documents: undefined });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadAll, setLoadAll] = useState(false);
|
||||
const { elementRef, isVisible: isLoadVisible } = useIsElementInViewport<HTMLButtonElement>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadVisible && !loadAll && state.documents != undefined) {
|
||||
loadMore();
|
||||
}
|
||||
}, [isLoadVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
console.log("load first", props.option);
|
||||
const load = async () => {
|
||||
try {
|
||||
const c = await ContentAccessor.findList(props.option);
|
||||
// todo : if c is undefined, retry to fetch 3 times. and show error message.
|
||||
setState({ documents: c });
|
||||
setLoadAll(c.length == 0);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
setError(e.message);
|
||||
} else {
|
||||
setError("unknown error");
|
||||
}
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [props.diff]);
|
||||
const queryString = toQueryString(props.option ?? {});
|
||||
if (state.documents === undefined && error == null) {
|
||||
return <LoadingCircle />;
|
||||
} else {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridRowGap: "1rem",
|
||||
}}
|
||||
>
|
||||
{props.option !== undefined && props.diff !== "" && (
|
||||
<Box>
|
||||
<Typography variant="h6">search for</Typography>
|
||||
{props.option.word !== undefined && (
|
||||
<Chip label={"search : " + decodeURIComponent(props.option.word)}></Chip>
|
||||
)}
|
||||
{props.option.content_type !== undefined && <Chip label={"type : " + props.option.content_type}></Chip>}
|
||||
{props.option.allow_tag !== undefined &&
|
||||
props.option.allow_tag.map((x) => (
|
||||
<TagChip key={x} tagname={decodeURIComponent(x)} label={decodeURIComponent(x)} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{state.documents &&
|
||||
state.documents.map((x) => {
|
||||
return <ContentInfo document={x} key={x.id} gallery={`/search?${queryString}`} short />;
|
||||
})}
|
||||
{error && <Typography variant="h5">Error : {error}</Typography>}
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{state.documents ? state.documents.length : "null"} loaded...
|
||||
</Typography>
|
||||
<Button onClick={() => loadMore()} disabled={loadAll} ref={elementRef}>
|
||||
{loadAll ? "Load All" : "Load More"}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
function loadMore() {
|
||||
let option = { ...props.option };
|
||||
console.log(elementRef);
|
||||
if (state.documents === undefined || state.documents.length === 0) {
|
||||
console.log("loadall");
|
||||
setLoadAll(true);
|
||||
return;
|
||||
}
|
||||
const prev_documents = state.documents;
|
||||
option.cursor = prev_documents[prev_documents.length - 1].id;
|
||||
console.log("load more", option);
|
||||
const load = async () => {
|
||||
const c = await ContentAccessor.findList(option);
|
||||
if (c.length === 0) {
|
||||
setLoadAll(true);
|
||||
} else {
|
||||
setState({ documents: [...prev_documents, ...c] });
|
||||
}
|
||||
};
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
export const Gallery = () => {
|
||||
const location = useLocation();
|
||||
const query = QueryStringToMap(location.search);
|
||||
const menu_list = CommonMenuList({ url: location.search });
|
||||
let option: QueryListOption = query;
|
||||
option.allow_tag = typeof option.allow_tag === "string" ? [option.allow_tag] : option.allow_tag;
|
||||
option.limit = typeof query["limit"] === "string" ? parseInt(query["limit"]) : undefined;
|
||||
return (
|
||||
<Headline menu={menu_list}>
|
||||
<PagePad>
|
||||
<GalleryInfo diff={location.search} option={query}></GalleryInfo>
|
||||
</PagePad>
|
||||
</Headline>
|
||||
);
|
||||
};
|
90
packages/client/src/page/login.tsx
Normal file
90
packages/client/src/page/login.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
MenuList,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CommonMenuList, Headline } from "../component/mod";
|
||||
import { UserContext } from "../state";
|
||||
import { doLogin as doSessionLogin } from "../state";
|
||||
import { PagePad } from "../component/pagepad";
|
||||
|
||||
export const LoginPage = () => {
|
||||
const theme = useTheme();
|
||||
const [userLoginInfo, setUserLoginInfo] = useState({ username: "", password: "" });
|
||||
const [openDialog, setOpenDialog] = useState({ open: false, message: "" });
|
||||
const { setUsername, setPermission } = useContext(UserContext);
|
||||
const navigate = useNavigate();
|
||||
const handleDialogClose = () => {
|
||||
setOpenDialog({ ...openDialog, open: false });
|
||||
};
|
||||
const doLogin = async () => {
|
||||
try {
|
||||
const b = await doSessionLogin(userLoginInfo);
|
||||
if (typeof b === "string") {
|
||||
setOpenDialog({ open: true, message: b });
|
||||
return;
|
||||
}
|
||||
console.log(`login as ${b.username}`);
|
||||
setUsername(b.username);
|
||||
setPermission(b.permission);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
console.error(e);
|
||||
setOpenDialog({ open: true, message: e.message });
|
||||
} else console.error(e);
|
||||
return;
|
||||
}
|
||||
navigate("/");
|
||||
};
|
||||
const menu = CommonMenuList();
|
||||
return (
|
||||
<Headline menu={menu}>
|
||||
<PagePad>
|
||||
<Paper style={{ width: theme.spacing(40), padding: theme.spacing(4), alignSelf: "center" }}>
|
||||
<Typography variant="h4">Login</Typography>
|
||||
<div style={{ minHeight: theme.spacing(2) }}></div>
|
||||
<form style={{ display: "flex", flexFlow: "column", alignItems: "stretch" }}>
|
||||
<TextField
|
||||
label="username"
|
||||
onChange={(e) => setUserLoginInfo({ ...userLoginInfo, username: e.target.value ?? "" })}
|
||||
></TextField>
|
||||
<TextField
|
||||
label="password"
|
||||
type="password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") doLogin();
|
||||
}}
|
||||
onChange={(e) => setUserLoginInfo({ ...userLoginInfo, password: e.target.value ?? "" })}
|
||||
/>
|
||||
<div style={{ minHeight: theme.spacing(2) }}></div>
|
||||
<div style={{ display: "flex" }}>
|
||||
<Button onClick={doLogin}>login</Button>
|
||||
<Button>signin</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Paper>
|
||||
<Dialog open={openDialog.open} onClose={handleDialogClose}>
|
||||
<DialogTitle>Login Failed</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>detail : {openDialog.message}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDialogClose} color="primary" autoFocus>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</PagePad>
|
||||
</Headline>
|
||||
);
|
||||
};
|
149
packages/client/src/page/profile.tsx
Normal file
149
packages/client/src/page/profile.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
Grid,
|
||||
Paper,
|
||||
TextField,
|
||||
Theme,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { CommonMenuList, Headline } from "../component/mod";
|
||||
import { UserContext } from "../state";
|
||||
import { PagePad } from "../component/pagepad";
|
||||
|
||||
const useStyles = (theme: Theme) => ({
|
||||
paper: {
|
||||
alignSelf: "center",
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
formfield: {
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
},
|
||||
});
|
||||
|
||||
export function ProfilePage() {
|
||||
const userctx = useContext(UserContext);
|
||||
// const classes = useStyles();
|
||||
const menu = CommonMenuList();
|
||||
const [pw_open, set_pw_open] = useState(false);
|
||||
const [oldpw, setOldpw] = useState("");
|
||||
const [newpw, setNewpw] = useState("");
|
||||
const [newpwch, setNewpwch] = useState("");
|
||||
const [msg_dialog, set_msg_dialog] = useState({ opened: false, msg: "" });
|
||||
const permission_list = userctx.permission.map((p) => <Chip key={p} label={p}></Chip>);
|
||||
const isElectronContent = ((window["electron"] as any) !== undefined) as boolean;
|
||||
const handle_open = () => set_pw_open(true);
|
||||
const handle_close = () => {
|
||||
set_pw_open(false);
|
||||
setNewpw("");
|
||||
setNewpwch("");
|
||||
};
|
||||
const handle_ok = async () => {
|
||||
if (newpw != newpwch) {
|
||||
set_msg_dialog({ opened: true, msg: "password and password check is not equal." });
|
||||
handle_close();
|
||||
return;
|
||||
}
|
||||
if (isElectronContent) {
|
||||
const elec = window["electron"] as any;
|
||||
const success = elec.passwordReset(userctx.username, newpw);
|
||||
if (!success) {
|
||||
set_msg_dialog({ opened: true, msg: "user not exist." });
|
||||
}
|
||||
} else {
|
||||
const res = await fetch("/user/reset", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: userctx.username,
|
||||
oldpassword: oldpw,
|
||||
newpassword: newpw,
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
if (res.status != 200) {
|
||||
set_msg_dialog({ opened: true, msg: "failed to change password." });
|
||||
}
|
||||
}
|
||||
handle_close();
|
||||
};
|
||||
return (
|
||||
<Headline menu={menu}>
|
||||
<PagePad>
|
||||
<Paper /*className={classes.paper}*/>
|
||||
<Grid container direction="column" alignItems="center">
|
||||
<Grid item>
|
||||
<Typography variant="h4">{userctx.username}</Typography>
|
||||
</Grid>
|
||||
<Divider></Divider>
|
||||
<Grid item>Permission</Grid>
|
||||
<Grid item>{permission_list.length == 0 ? "-" : permission_list}</Grid>
|
||||
<Grid item>
|
||||
<Button onClick={handle_open}>Password Reset</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
<Dialog open={pw_open} onClose={handle_close}>
|
||||
<DialogTitle>Password Reset</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>type the old and new password</Typography>
|
||||
<div /*className={classes.formfield}*/>
|
||||
{!isElectronContent && (
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
type="password"
|
||||
label="old password"
|
||||
value={oldpw}
|
||||
onChange={(e) => setOldpw(e.target.value)}
|
||||
></TextField>
|
||||
)}
|
||||
<TextField
|
||||
margin="dense"
|
||||
type="password"
|
||||
label="new password"
|
||||
value={newpw}
|
||||
onChange={(e) => setNewpw(e.target.value)}
|
||||
></TextField>
|
||||
<TextField
|
||||
margin="dense"
|
||||
type="password"
|
||||
label="new password check"
|
||||
value={newpwch}
|
||||
onChange={(e) => setNewpwch(e.target.value)}
|
||||
></TextField>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handle_close} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handle_ok} color="primary">
|
||||
Ok
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog open={msg_dialog.opened} onClose={() => set_msg_dialog({ opened: false, msg: "" })}>
|
||||
<DialogTitle>Alert!</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{msg_dialog.msg}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => set_msg_dialog({ opened: false, msg: "" })} color="primary">
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</PagePad>
|
||||
</Headline>
|
||||
);
|
||||
}
|
83
packages/client/src/page/reader/comic.tsx
Normal file
83
packages/client/src/page/reader/comic.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { Typography, styled } from "@mui/material";
|
||||
import React, { RefObject, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Document } from "../../accessor/document";
|
||||
|
||||
type ComicType = "comic" | "artist cg" | "donjinshi" | "western";
|
||||
|
||||
export type PresentableTag = {
|
||||
artist: string[];
|
||||
group: string[];
|
||||
series: string[];
|
||||
type: ComicType;
|
||||
character: string[];
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
const ViewMain = styled("div")(({ theme }) => ({
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
height: "calc(100vh - 64px)",
|
||||
position: "relative",
|
||||
}));
|
||||
const CurrentView = styled("img")(({ theme }) => ({
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%,-50%)",
|
||||
position: "absolute",
|
||||
}));
|
||||
|
||||
export const ComicReader = (props: { doc: Document; fullScreenTarget?: RefObject<HTMLDivElement> }) => {
|
||||
const additional = props.doc.additional;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const curPage = parseInt(searchParams.get("page") ?? "0");
|
||||
const setCurPage = (n: number) => {
|
||||
setSearchParams([["page", n.toString()]]);
|
||||
};
|
||||
if (isNaN(curPage)) {
|
||||
return <Typography>Error. Page number is not a number.</Typography>;
|
||||
}
|
||||
if (!("page" in additional)) {
|
||||
console.error("invalid content : page read fail : " + JSON.stringify(additional));
|
||||
return <Typography>Error. DB error. page restriction</Typography>;
|
||||
}
|
||||
|
||||
const maxPage: number = additional["page"] as number;
|
||||
const PageDown = () => setCurPage(Math.max(curPage - 1, 0));
|
||||
const PageUp = () => setCurPage(Math.min(curPage + 1, maxPage - 1));
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
console.log(`currently: ${curPage}/${maxPage}`);
|
||||
if (e.code === "ArrowLeft") {
|
||||
PageDown();
|
||||
} else if (e.code === "ArrowRight") {
|
||||
PageUp();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", onKeyUp);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyUp);
|
||||
};
|
||||
}, [curPage]);
|
||||
// theme.mixins.toolbar.minHeight;
|
||||
return (
|
||||
<ViewMain ref={props.fullScreenTarget}>
|
||||
<div
|
||||
onClick={PageDown}
|
||||
style={{ left: "0", width: "50%", position: "absolute", height: "100%", zIndex: 100 }}
|
||||
></div>
|
||||
<CurrentView onClick={PageUp} src={`/api/doc/${props.doc.id}/comic/${curPage}`}></CurrentView>
|
||||
<div
|
||||
onClick={PageUp}
|
||||
style={{ right: "0", width: "50%", position: "absolute", height: "100%", zIndex: 100 }}
|
||||
></div>
|
||||
</ViewMain>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComicReader;
|
80
packages/client/src/page/reader/reader.tsx
Normal file
80
packages/client/src/page/reader/reader.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { styled, Typography } from "@mui/material";
|
||||
import React from "react";
|
||||
import { Document, makeThumbnailUrl } from "../../accessor/document";
|
||||
import { ComicReader } from "./comic";
|
||||
import { VideoReader } from "./video";
|
||||
|
||||
export interface PagePresenterProp {
|
||||
doc: Document;
|
||||
className?: string;
|
||||
fullScreenTarget?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
interface PagePresenter {
|
||||
(prop: PagePresenterProp): JSX.Element;
|
||||
}
|
||||
|
||||
export const getPresenter = (content: Document): PagePresenter => {
|
||||
switch (content.content_type) {
|
||||
case "comic":
|
||||
return ComicReader;
|
||||
case "video":
|
||||
return VideoReader;
|
||||
}
|
||||
return () => <Typography variant="h2">Not implemented reader</Typography>;
|
||||
};
|
||||
const BackgroundDiv = styled("div")({
|
||||
height: "400px",
|
||||
width: "300px",
|
||||
backgroundColor: "#272733",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
});
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import "./thumbnail.css";
|
||||
|
||||
export function useIsElementInViewport<T extends HTMLElement>(options?: IntersectionObserverInit) {
|
||||
const elementRef = useRef<T>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const callback = (entries: IntersectionObserverEntry[]) => {
|
||||
const [entry] = entries;
|
||||
setIsVisible(entry.isIntersecting);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(callback, options);
|
||||
elementRef.current && observer.observe(elementRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [elementRef, options]);
|
||||
|
||||
return { elementRef, isVisible };
|
||||
}
|
||||
|
||||
export function ThumbnailContainer(props: {
|
||||
content: Document;
|
||||
className?: string;
|
||||
}) {
|
||||
const { elementRef, isVisible } = useIsElementInViewport<HTMLDivElement>({});
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [isVisible]);
|
||||
const style = {
|
||||
maxHeight: "400px",
|
||||
maxWidth: "min(400px, 100vw)",
|
||||
};
|
||||
const thumbnailurl = makeThumbnailUrl(props.content);
|
||||
if (props.content.content_type === "video") {
|
||||
return <video src={thumbnailurl} muted autoPlay loop className={props.className} style={style}></video>;
|
||||
} else {
|
||||
return (
|
||||
<BackgroundDiv ref={elementRef}>
|
||||
{loaded && <img src={thumbnailurl} className={props.className + " thumbnail_img"} loading="lazy"></img>}
|
||||
</BackgroundDiv>
|
||||
);
|
||||
}
|
||||
}
|
14
packages/client/src/page/reader/video.tsx
Normal file
14
packages/client/src/page/reader/video.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import { Document } from "../../accessor/document";
|
||||
|
||||
export const VideoReader = (props: { doc: Document }) => {
|
||||
const id = props.doc.id;
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
autoPlay
|
||||
src={`/api/doc/${props.doc.id}/video`}
|
||||
style={{ maxHeight: "100%", maxWidth: "100%" }}
|
||||
></video>
|
||||
);
|
||||
};
|
17
packages/client/src/page/setting.tsx
Normal file
17
packages/client/src/page/setting.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Paper, Typography } from "@mui/material";
|
||||
import React from "react";
|
||||
import { CommonMenuList, Headline } from "../component/mod";
|
||||
import { PagePad } from "../component/pagepad";
|
||||
|
||||
export const SettingPage = () => {
|
||||
const menu = CommonMenuList();
|
||||
return (
|
||||
<Headline menu={menu}>
|
||||
<PagePad>
|
||||
<Paper>
|
||||
<Typography variant="h2">Setting</Typography>
|
||||
</Paper>
|
||||
</PagePad>
|
||||
</Headline>
|
||||
);
|
||||
};
|
76
packages/client/src/page/tags.tsx
Normal file
76
packages/client/src/page/tags.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { Box, Paper, Typography } from "@mui/material";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { LoadingCircle } from "../component/loading";
|
||||
import { CommonMenuList, Headline } from "../component/mod";
|
||||
import { PagePad } from "../component/pagepad";
|
||||
|
||||
type TagCount = {
|
||||
tag_name: string;
|
||||
occurs: number;
|
||||
};
|
||||
|
||||
const tagTableColumn: GridColDef[] = [
|
||||
{
|
||||
field: "tag_name",
|
||||
headerName: "Tag Name",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
field: "occurs",
|
||||
headerName: "Occurs",
|
||||
width: 100,
|
||||
type: "number",
|
||||
},
|
||||
];
|
||||
|
||||
function TagTable() {
|
||||
const [data, setData] = useState<TagCount[] | undefined>();
|
||||
const [error, setErrorMsg] = useState<string | undefined>(undefined);
|
||||
const isLoading = data === undefined;
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingCircle />;
|
||||
}
|
||||
if (error !== undefined) {
|
||||
return <Typography variant="h3">{error}</Typography>;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ height: "400px", width: "100%" }}>
|
||||
<Paper sx={{ height: "100%" }} elevation={2}>
|
||||
<DataGrid rows={data} columns={tagTableColumn} getRowId={(t) => t.tag_name}></DataGrid>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const res = await fetch("/api/tags?withCount=true");
|
||||
const data = await res.json();
|
||||
setData(data);
|
||||
} catch (e) {
|
||||
setData([]);
|
||||
if (e instanceof Error) {
|
||||
setErrorMsg(e.message);
|
||||
} else {
|
||||
console.log(e);
|
||||
setErrorMsg("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const TagsPage = () => {
|
||||
const menu = CommonMenuList();
|
||||
return (
|
||||
<Headline menu={menu}>
|
||||
<PagePad>
|
||||
<TagTable></TagTable>
|
||||
</PagePad>
|
||||
</Headline>
|
||||
);
|
||||
};
|
94
packages/client/src/state.tsx
Normal file
94
packages/client/src/state.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import React, { createContext, useRef, useState } from "react";
|
||||
export const BackLinkContext = createContext({ backLink: "", setBackLink: (s: string) => {} });
|
||||
export const UserContext = createContext({
|
||||
username: "",
|
||||
permission: [] as string[],
|
||||
setUsername: (s: string) => {},
|
||||
setPermission: (permission: string[]) => {},
|
||||
});
|
||||
|
||||
type LoginLocalStorage = {
|
||||
username: string;
|
||||
permission: string[];
|
||||
accessExpired: number;
|
||||
};
|
||||
|
||||
let localObj: LoginLocalStorage | null = null;
|
||||
|
||||
export const getInitialValue = async () => {
|
||||
if (localObj === null) {
|
||||
const storagestr = window.localStorage.getItem("UserLoginContext") as string | null;
|
||||
const storage = storagestr !== null ? (JSON.parse(storagestr) as LoginLocalStorage | null) : null;
|
||||
localObj = storage;
|
||||
}
|
||||
if (localObj !== null && localObj.accessExpired > Math.floor(Date.now() / 1000)) {
|
||||
return {
|
||||
username: localObj.username,
|
||||
permission: localObj.permission,
|
||||
};
|
||||
}
|
||||
const res = await fetch("/user/refresh", {
|
||||
method: "POST",
|
||||
});
|
||||
if (res.status !== 200) throw new Error("Maybe Network Error");
|
||||
const r = (await res.json()) as LoginLocalStorage & { refresh: boolean };
|
||||
if (r.refresh) {
|
||||
localObj = {
|
||||
username: r.username,
|
||||
permission: r.permission,
|
||||
accessExpired: r.accessExpired,
|
||||
};
|
||||
} else {
|
||||
localObj = {
|
||||
accessExpired: 0,
|
||||
username: "",
|
||||
permission: r.permission,
|
||||
};
|
||||
}
|
||||
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
||||
return {
|
||||
username: r.username,
|
||||
permission: r.permission,
|
||||
};
|
||||
};
|
||||
export const doLogout = async () => {
|
||||
const req = await fetch("/user/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
try {
|
||||
const res = await req.json();
|
||||
localObj = {
|
||||
accessExpired: 0,
|
||||
username: "",
|
||||
permission: res["permission"],
|
||||
};
|
||||
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
||||
return {
|
||||
username: localObj.username,
|
||||
permission: localObj.permission,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Server Error ${error}`);
|
||||
return {
|
||||
username: "",
|
||||
permission: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
export const doLogin = async (userLoginInfo: {
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<string | LoginLocalStorage> => {
|
||||
const res = await fetch("/user/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(userLoginInfo),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const b = await res.json();
|
||||
if (res.status !== 200) {
|
||||
return b.detail as string;
|
||||
}
|
||||
localObj = b;
|
||||
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
||||
return b;
|
||||
};
|
1
packages/client/src/vite-env.d.ts
vendored
Normal file
1
packages/client/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
25
packages/client/tsconfig.json
Normal file
25
packages/client/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
11
packages/client/tsconfig.node.json
Normal file
11
packages/client/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
7
packages/client/vite.config.ts
Normal file
7
packages/client/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
18
packages/dbtype/package.json
Normal file
18
packages/dbtype/package.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "dbtype",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.9",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"kysely": "^0.27.3",
|
||||
"kysely-codegen": "^0.14.1"
|
||||
}
|
||||
}
|
53
packages/dbtype/types.ts
Normal file
53
packages/dbtype/types.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import type { ColumnType } from "kysely";
|
||||
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
|
||||
export interface DocTagRelation {
|
||||
doc_id: number;
|
||||
tag_name: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
additional: string | null;
|
||||
basepath: string;
|
||||
content_hash: string | null;
|
||||
content_type: string;
|
||||
created_at: number;
|
||||
deleted_at: number | null;
|
||||
filename: string;
|
||||
id: Generated<number>;
|
||||
modified_at: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Permissions {
|
||||
name: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface SchemaMigration {
|
||||
dirty: number | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
export interface Tags {
|
||||
description: string | null;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface Users {
|
||||
password_hash: string;
|
||||
password_salt: string;
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
doc_tag_relation: DocTagRelation;
|
||||
document: Document;
|
||||
permissions: Permissions;
|
||||
schema_migration: SchemaMigration;
|
||||
tags: Tags;
|
||||
users: Users;
|
||||
}
|
145
packages/server/app.ts
Normal file
145
packages/server/app.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { app, BrowserWindow, dialog, session } from "electron";
|
||||
import { ipcMain } from "electron";
|
||||
import { join } from "path";
|
||||
import { accessTokenName, getAdminAccessTokenValue, getAdminRefreshTokenValue, refreshTokenName } from "./src/login";
|
||||
import { UserAccessor } from "./src/model/mod";
|
||||
import { create_server } from "./src/server";
|
||||
import { get_setting } from "./src/SettingConfig";
|
||||
|
||||
function registerChannel(cntr: UserAccessor) {
|
||||
ipcMain.handle("reset_password", async (event, username: string, password: string) => {
|
||||
const user = await cntr.findUser(username);
|
||||
if (user === undefined) {
|
||||
return false;
|
||||
}
|
||||
user.reset_password(password);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
const setting = get_setting();
|
||||
if (!setting.cli) {
|
||||
let wnd: BrowserWindow | null = null;
|
||||
|
||||
const createWindow = async () => {
|
||||
wnd = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
center: true,
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
},
|
||||
});
|
||||
await wnd.loadURL(`data:text/html;base64,` + Buffer.from(loading_html).toString("base64"));
|
||||
// await wnd.loadURL('../loading.html');
|
||||
// set admin cookies.
|
||||
await session.defaultSession.cookies.set({
|
||||
url: `http://localhost:${setting.port}`,
|
||||
name: accessTokenName,
|
||||
value: getAdminAccessTokenValue(),
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
});
|
||||
await session.defaultSession.cookies.set({
|
||||
url: `http://localhost:${setting.port}`,
|
||||
name: refreshTokenName,
|
||||
value: getAdminRefreshTokenValue(),
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
});
|
||||
try {
|
||||
const server = await create_server();
|
||||
const app = server.start_server();
|
||||
registerChannel(server.userController);
|
||||
await wnd.loadURL(`http://localhost:${setting.port}`);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
title: "error!",
|
||||
message: e.message,
|
||||
});
|
||||
} else {
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
title: "error!",
|
||||
message: String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
wnd.on("closed", () => {
|
||||
wnd = null;
|
||||
});
|
||||
};
|
||||
|
||||
const isPrimary = app.requestSingleInstanceLock();
|
||||
if (!isPrimary) {
|
||||
app.quit(); // exit window
|
||||
app.exit();
|
||||
}
|
||||
app.on("second-instance", () => {
|
||||
if (wnd != null) {
|
||||
if (wnd.isMinimized()) {
|
||||
wnd.restore();
|
||||
}
|
||||
wnd.focus();
|
||||
}
|
||||
});
|
||||
app.on("ready", (event, info) => {
|
||||
createWindow();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
// quit when all windows are closed
|
||||
if (process.platform != "darwin") app.quit(); // (except leave MacOS app active until Cmd+Q)
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
// re-recreate window when dock icon is clicked and no other windows open
|
||||
if (wnd == null) createWindow();
|
||||
});
|
||||
} else {
|
||||
(async () => {
|
||||
try {
|
||||
const server = await create_server();
|
||||
server.start_server();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
const loading_html = `<!DOCTYPE html>
|
||||
<html lang="ko"><head>
|
||||
<meta charset="UTF-8">
|
||||
<title>loading</title>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'
|
||||
fonts.googleapis.com; font-src 'self' fonts.gstatic.com">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<style>
|
||||
body { margin-top: 100px; background-color: #3f51b5; color: #fff; text-align:center; }
|
||||
h1 {
|
||||
font: 2em 'Roboto', sans-serif;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
#loading {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid rgba(255,255,255,.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg);}
|
||||
}
|
||||
</style>
|
||||
<body>
|
||||
<h1>Loading...</h1>
|
||||
<div id="loading"></div>
|
||||
</body>
|
||||
</html>`;
|
50
packages/server/gen_conf_schema.ts
Normal file
50
packages/server/gen_conf_schema.ts
Normal file
@ -0,0 +1,50 @@
|
||||
// import { promises } from "fs";
|
||||
// const { readdir, writeFile } = promises;
|
||||
// import { dirname, join } from "path";
|
||||
// import { createGenerator } from "ts-json-schema-generator";
|
||||
|
||||
// async function genSchema(path: string, typename: string) {
|
||||
// const gen = createGenerator({
|
||||
// path: path,
|
||||
// type: typename,
|
||||
// tsconfig: "tsconfig.json",
|
||||
// });
|
||||
// const schema = gen.createSchema(typename);
|
||||
// if (schema.definitions != undefined) {
|
||||
// const definitions = schema.definitions;
|
||||
// const definition = definitions[typename];
|
||||
// if (typeof definition == "object") {
|
||||
// let property = definition.properties;
|
||||
// if (property) {
|
||||
// property["$schema"] = {
|
||||
// type: "string",
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// const text = JSON.stringify(schema);
|
||||
// await writeFile(join(dirname(path), `${typename}.schema.json`), text);
|
||||
// }
|
||||
// function capitalize(s: string) {
|
||||
// return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
// }
|
||||
// async function setToALL(path: string) {
|
||||
// console.log(`scan ${path}`);
|
||||
// const direntry = await readdir(path, { withFileTypes: true });
|
||||
// const works = direntry
|
||||
// .filter((x) => x.isFile() && x.name.endsWith("Config.ts"))
|
||||
// .map((x) => {
|
||||
// const name = x.name;
|
||||
// const m = /(.+)\.ts/.exec(name);
|
||||
// if (m !== null) {
|
||||
// const typename = m[1];
|
||||
// return genSchema(join(path, typename), capitalize(typename));
|
||||
// }
|
||||
// });
|
||||
// await Promise.all(works);
|
||||
// const subdir = direntry.filter((x) => x.isDirectory()).map((x) => x.name);
|
||||
// for (const x of subdir) {
|
||||
// await setToALL(join(path, x));
|
||||
// }
|
||||
// }
|
||||
// setToALL("src");
|
56
packages/server/migrations/initial.ts
Normal file
56
packages/server/migrations/initial.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex) {
|
||||
await knex.schema.createTable("schema_migration", (b) => {
|
||||
b.string("version");
|
||||
b.boolean("dirty");
|
||||
});
|
||||
|
||||
await knex.schema.createTable("users", (b) => {
|
||||
b.string("username").primary().comment("user's login id");
|
||||
b.string("password_hash", 64).notNullable();
|
||||
b.string("password_salt", 64).notNullable();
|
||||
});
|
||||
await knex.schema.createTable("document", (b) => {
|
||||
b.increments("id").primary();
|
||||
b.string("title").notNullable();
|
||||
b.string("content_type", 16).notNullable();
|
||||
b.string("basepath", 256).notNullable().comment("directory path for resource");
|
||||
b.string("filename", 256).notNullable().comment("filename");
|
||||
b.string("content_hash").nullable();
|
||||
b.json("additional").nullable();
|
||||
b.integer("created_at").notNullable();
|
||||
b.integer("modified_at").notNullable();
|
||||
b.integer("deleted_at");
|
||||
b.index("content_type", "content_type_index");
|
||||
});
|
||||
await knex.schema.createTable("tags", (b) => {
|
||||
b.string("name").primary();
|
||||
b.text("description");
|
||||
});
|
||||
await knex.schema.createTable("doc_tag_relation", (b) => {
|
||||
b.integer("doc_id").unsigned().notNullable();
|
||||
b.string("tag_name").notNullable();
|
||||
b.foreign("doc_id").references("document.id");
|
||||
b.foreign("tag_name").references("tags.name");
|
||||
b.primary(["doc_id", "tag_name"]);
|
||||
});
|
||||
await knex.schema.createTable("permissions", (b) => {
|
||||
b.string("username").notNullable();
|
||||
b.string("name").notNullable();
|
||||
b.primary(["username", "name"]);
|
||||
b.foreign("username").references("users.username");
|
||||
});
|
||||
// create admin account.
|
||||
await knex
|
||||
.insert({
|
||||
username: "admin",
|
||||
password_hash: "unchecked",
|
||||
password_salt: "unchecked",
|
||||
})
|
||||
.into("users");
|
||||
}
|
||||
|
||||
export async function down(knex: Knex) {
|
||||
throw new Error("Downward migrations are not supported. Restore from backup.");
|
||||
}
|
38
packages/server/package.json
Normal file
38
packages/server/package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "followed",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "build/app.js",
|
||||
"scripts": {
|
||||
"compile": "tsc",
|
||||
"compile:watch": "tsc -w",
|
||||
"build": "cd src/client && pnpm run build:prod",
|
||||
"build:watch": "cd src/client && pnpm run build:watch",
|
||||
"start": "node build/app.js"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "^2.7.40",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"chokidar": "^3.6.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"koa": "^2.15.2",
|
||||
"koa-bodyparser": "^4.4.1",
|
||||
"koa-compose": "^4.1.0",
|
||||
"koa-router": "^12.0.1",
|
||||
"kysely": "^0.27.3",
|
||||
"natural-orderby": "^2.0.3",
|
||||
"tiny-async-pool": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dbtype": "*",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/koa": "^2.15.0",
|
||||
"@types/koa-bodyparser": "^4.3.12",
|
||||
"@types/koa-compose": "^3.2.8",
|
||||
"@types/koa-router": "^7.4.8",
|
||||
"@types/node": "^14.18.63",
|
||||
"@types/tiny-async-pool": "^1.0.5"
|
||||
}
|
||||
}
|
7
packages/server/preload.ts
Normal file
7
packages/server/preload.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
// contextBridge.exposeInMainWorld("electron", {
|
||||
// passwordReset: async (username: string, toPw: string) => {
|
||||
// return await ipcRenderer.invoke("reset_password", username, toPw);
|
||||
// },
|
||||
// });
|
51
packages/server/src/SettingConfig.schema.json
Normal file
51
packages/server/src/SettingConfig.schema.json
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$ref": "#/definitions/SettingConfig",
|
||||
"definitions": {
|
||||
"SettingConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"localmode": {
|
||||
"type": "boolean",
|
||||
"description": "if true, server will bind on '127.0.0.1' rather than '0.0.0.0'"
|
||||
},
|
||||
"guest": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Permission"
|
||||
},
|
||||
"description": "guest permission"
|
||||
},
|
||||
"jwt_secretkey": {
|
||||
"type": "string",
|
||||
"description": "JWT secret key. if you change its value, all access tokens are invalidated."
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "the port which running server is binding on."
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["development", "production"]
|
||||
},
|
||||
"cli": {
|
||||
"type": "boolean",
|
||||
"description": "if true, do not show 'electron' window and show terminal only."
|
||||
},
|
||||
"forbid_remote_admin_login": {
|
||||
"type": "boolean",
|
||||
"description": "forbid to login admin from remote client. but, it do not invalidate access token. \r if you want to invalidate access token, change 'jwt_secretkey'."
|
||||
},
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["localmode", "guest", "jwt_secretkey", "port", "mode", "cli", "forbid_remote_admin_login"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Permission": {
|
||||
"type": "string",
|
||||
"enum": ["ModifyTag", "QueryContent", "ModifyTagDesc"]
|
||||
}
|
||||
}
|
||||
}
|
79
packages/server/src/SettingConfig.ts
Normal file
79
packages/server/src/SettingConfig.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { Permission } from "./permission/permission";
|
||||
|
||||
export interface SettingConfig {
|
||||
/**
|
||||
* if true, server will bind on '127.0.0.1' rather than '0.0.0.0'
|
||||
*/
|
||||
localmode: boolean;
|
||||
/**
|
||||
* secure only
|
||||
*/
|
||||
secure: boolean;
|
||||
|
||||
/**
|
||||
* guest permission
|
||||
*/
|
||||
guest: Permission[];
|
||||
/**
|
||||
* JWT secret key. if you change its value, all access tokens are invalidated.
|
||||
*/
|
||||
jwt_secretkey: string;
|
||||
/**
|
||||
* the port which running server is binding on.
|
||||
*/
|
||||
port: number;
|
||||
|
||||
mode: "development" | "production";
|
||||
/**
|
||||
* if true, do not show 'electron' window and show terminal only.
|
||||
*/
|
||||
cli: boolean;
|
||||
/** forbid to login admin from remote client. but, it do not invalidate access token.
|
||||
* if you want to invalidate access token, change 'jwt_secretkey'. */
|
||||
forbid_remote_admin_login: boolean;
|
||||
}
|
||||
const default_setting: SettingConfig = {
|
||||
localmode: true,
|
||||
secure: true,
|
||||
guest: [],
|
||||
jwt_secretkey: "itsRandom",
|
||||
port: 8080,
|
||||
mode: "production",
|
||||
cli: false,
|
||||
forbid_remote_admin_login: true,
|
||||
};
|
||||
let setting: null | SettingConfig = null;
|
||||
|
||||
const setEmptyToDefault = (target: any, default_table: SettingConfig) => {
|
||||
let diff_occur = false;
|
||||
for (const key in default_table) {
|
||||
if (key === undefined || key in target) {
|
||||
continue;
|
||||
}
|
||||
target[key] = default_table[key as keyof SettingConfig];
|
||||
diff_occur = true;
|
||||
}
|
||||
return diff_occur;
|
||||
};
|
||||
|
||||
export const read_setting_from_file = () => {
|
||||
let ret = existsSync("settings.json") ? JSON.parse(readFileSync("settings.json", { encoding: "utf8" })) : {};
|
||||
const partial_occur = setEmptyToDefault(ret, default_setting);
|
||||
if (partial_occur) {
|
||||
writeFileSync("settings.json", JSON.stringify(ret));
|
||||
}
|
||||
return ret as SettingConfig;
|
||||
};
|
||||
export function get_setting(): SettingConfig {
|
||||
if (setting === null) {
|
||||
setting = read_setting_from_file();
|
||||
const env = process.env.NODE_ENV;
|
||||
if (env !== undefined && env != "production" && env != "development") {
|
||||
throw new Error('process unknown value in NODE_ENV: must be either "development" or "production"');
|
||||
}
|
||||
setting.mode = env ?? setting.mode;
|
||||
}
|
||||
return setting;
|
||||
}
|
22
packages/server/src/config.ts
Normal file
22
packages/server/src/config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Knex as k } from "knex";
|
||||
|
||||
export namespace Knex {
|
||||
export const config: {
|
||||
development: k.Config;
|
||||
production: k.Config;
|
||||
} = {
|
||||
development: {
|
||||
client: "sqlite3",
|
||||
connection: {
|
||||
filename: "./devdb.sqlite3",
|
||||
},
|
||||
debug: true,
|
||||
},
|
||||
production: {
|
||||
client: "sqlite3",
|
||||
connection: {
|
||||
filename: "./db.sqlite3",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
66
packages/server/src/content/comic.ts
Normal file
66
packages/server/src/content/comic.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { extname } from "path";
|
||||
import { DocumentBody } from "../model/doc";
|
||||
import { readAllFromZip, readZip } from "../util/zipwrap";
|
||||
import { ContentConstructOption, ContentFile, createDefaultClass, registerContentReferrer } from "./file";
|
||||
|
||||
type ComicType = "doujinshi" | "artist cg" | "manga" | "western";
|
||||
interface ComicDesc {
|
||||
title: string;
|
||||
artist?: string[];
|
||||
group?: string[];
|
||||
series?: string[];
|
||||
type: ComicType | [ComicType];
|
||||
character?: string[];
|
||||
tags?: string[];
|
||||
}
|
||||
const ImageExt = [".gif", ".png", ".jpeg", ".bmp", ".webp", ".jpg"];
|
||||
export class ComicReferrer extends createDefaultClass("comic") {
|
||||
desc: ComicDesc | undefined;
|
||||
pagenum: number;
|
||||
additional: ContentConstructOption | undefined;
|
||||
constructor(path: string, option?: ContentConstructOption) {
|
||||
super(path);
|
||||
this.additional = option;
|
||||
this.pagenum = 0;
|
||||
}
|
||||
async initDesc(): Promise<void> {
|
||||
if (this.desc !== undefined) return;
|
||||
const zip = await readZip(this.path);
|
||||
const entries = await zip.entries();
|
||||
this.pagenum = Object.keys(entries).filter((x) => ImageExt.includes(extname(x))).length;
|
||||
const entry = entries["desc.json"];
|
||||
if (entry === undefined) {
|
||||
return;
|
||||
}
|
||||
const data = (await readAllFromZip(zip, entry)).toString("utf-8");
|
||||
this.desc = JSON.parse(data);
|
||||
if (this.desc === undefined) {
|
||||
throw new Error(`JSON.parse is returning undefined. ${this.path} desc.json format error`);
|
||||
}
|
||||
}
|
||||
|
||||
async createDocumentBody(): Promise<DocumentBody> {
|
||||
await this.initDesc();
|
||||
const basebody = await super.createDocumentBody();
|
||||
this.desc?.title;
|
||||
if (this.desc === undefined) {
|
||||
return basebody;
|
||||
}
|
||||
let tags: string[] = this.desc.tags ?? [];
|
||||
tags = tags.concat(this.desc.artist?.map((x) => `artist:${x}`) ?? []);
|
||||
tags = tags.concat(this.desc.character?.map((x) => `character:${x}`) ?? []);
|
||||
tags = tags.concat(this.desc.group?.map((x) => `group:${x}`) ?? []);
|
||||
tags = tags.concat(this.desc.series?.map((x) => `series:${x}`) ?? []);
|
||||
const type = this.desc.type instanceof Array ? this.desc.type[0] : this.desc.type;
|
||||
tags.push(`type:${type}`);
|
||||
return {
|
||||
...basebody,
|
||||
title: this.desc.title,
|
||||
additional: {
|
||||
page: this.pagenum,
|
||||
},
|
||||
tags: tags,
|
||||
};
|
||||
}
|
||||
}
|
||||
registerContentReferrer(ComicReferrer);
|
93
packages/server/src/content/file.ts
Normal file
93
packages/server/src/content/file.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { createHash } from "crypto";
|
||||
import { promises, Stats } from "fs";
|
||||
import { Context, DefaultContext, DefaultState, Middleware, Next } from "koa";
|
||||
import Router from "koa-router";
|
||||
import { extname } from "path";
|
||||
import path from "path";
|
||||
import { DocumentBody } from "../model/mod";
|
||||
/**
|
||||
* content file or directory referrer
|
||||
*/
|
||||
export interface ContentFile {
|
||||
getHash(): Promise<string>;
|
||||
createDocumentBody(): Promise<DocumentBody>;
|
||||
readonly path: string;
|
||||
readonly type: string;
|
||||
}
|
||||
export type ContentConstructOption = {
|
||||
hash: string;
|
||||
};
|
||||
type ContentFileConstructor = (new (
|
||||
path: string,
|
||||
option?: ContentConstructOption,
|
||||
) => ContentFile) & {
|
||||
content_type: string;
|
||||
};
|
||||
export const createDefaultClass = (type: string): ContentFileConstructor => {
|
||||
let cons = class implements ContentFile {
|
||||
readonly path: string;
|
||||
// type = type;
|
||||
static content_type = type;
|
||||
protected hash: string | undefined;
|
||||
protected stat: Stats | undefined;
|
||||
|
||||
constructor(path: string, option?: ContentConstructOption) {
|
||||
this.path = path;
|
||||
this.hash = option?.hash;
|
||||
this.stat = undefined;
|
||||
}
|
||||
async createDocumentBody(): Promise<DocumentBody> {
|
||||
const { base, dir, name } = path.parse(this.path);
|
||||
|
||||
const ret = {
|
||||
title: name,
|
||||
basepath: dir,
|
||||
additional: {},
|
||||
content_type: cons.content_type,
|
||||
filename: base,
|
||||
tags: [],
|
||||
content_hash: await this.getHash(),
|
||||
modified_at: await this.getMtime(),
|
||||
} as DocumentBody;
|
||||
return ret;
|
||||
}
|
||||
get type(): string {
|
||||
return cons.content_type;
|
||||
}
|
||||
async getHash(): Promise<string> {
|
||||
if (this.hash !== undefined) return this.hash;
|
||||
this.stat = await promises.stat(this.path);
|
||||
const hash = createHash("sha512");
|
||||
hash.update(extname(this.path));
|
||||
hash.update(this.stat.mode.toString());
|
||||
// if(this.desc !== undefined)
|
||||
// hash.update(JSON.stringify(this.desc));
|
||||
hash.update(this.stat.size.toString());
|
||||
this.hash = hash.digest("base64");
|
||||
return this.hash;
|
||||
}
|
||||
async getMtime(): Promise<number> {
|
||||
if (this.stat !== undefined) return this.stat.mtimeMs;
|
||||
await this.getHash();
|
||||
return this.stat!.mtimeMs;
|
||||
}
|
||||
};
|
||||
return cons;
|
||||
};
|
||||
let ContstructorTable: { [k: string]: ContentFileConstructor } = {};
|
||||
export function registerContentReferrer(s: ContentFileConstructor) {
|
||||
console.log(`registered content type: ${s.content_type}`);
|
||||
ContstructorTable[s.content_type] = s;
|
||||
}
|
||||
export function createContentFile(type: string, path: string, option?: ContentConstructOption) {
|
||||
const constructorMethod = ContstructorTable[type];
|
||||
if (constructorMethod === undefined) {
|
||||
console.log(`${type} are not in ${JSON.stringify(ContstructorTable)}`);
|
||||
throw new Error("construction method of the content type is undefined");
|
||||
}
|
||||
return new constructorMethod(path, option);
|
||||
}
|
||||
export function getContentFileConstructor(type: string): ContentFileConstructor | undefined {
|
||||
const ret = ContstructorTable[type];
|
||||
return ret;
|
||||
}
|
47
packages/server/src/database.ts
Normal file
47
packages/server/src/database.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { existsSync } from "fs";
|
||||
import Knex from "knex";
|
||||
import { Knex as KnexConfig } from "./config";
|
||||
import { get_setting } from "./SettingConfig";
|
||||
|
||||
export async function connectDB() {
|
||||
const env = get_setting().mode;
|
||||
const config = KnexConfig.config[env];
|
||||
if (!config.connection) {
|
||||
throw new Error("connection options required.");
|
||||
}
|
||||
const connection = config.connection;
|
||||
if (typeof connection === "string") {
|
||||
throw new Error("unknown connection options");
|
||||
}
|
||||
if (typeof connection === "function") {
|
||||
throw new Error("connection provider not supported...");
|
||||
}
|
||||
if (!("filename" in connection)) {
|
||||
throw new Error("sqlite3 config need");
|
||||
}
|
||||
const init_need = !existsSync(connection.filename);
|
||||
const knex = Knex(config);
|
||||
let tries = 0;
|
||||
for (;;) {
|
||||
try {
|
||||
console.log("try to connect db");
|
||||
await knex.raw("select 1 + 1;");
|
||||
console.log("connect success");
|
||||
} catch (err) {
|
||||
if (tries < 3) {
|
||||
tries++;
|
||||
console.error(`connection fail ${err} retry...`);
|
||||
continue;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (init_need) {
|
||||
console.log("first execute: initialize database...");
|
||||
const migrate = await import("../migrations/initial");
|
||||
await migrate.up(knex);
|
||||
}
|
||||
return knex;
|
||||
}
|
235
packages/server/src/db/doc.ts
Normal file
235
packages/server/src/db/doc.ts
Normal file
@ -0,0 +1,235 @@
|
||||
import { Knex } from "knex";
|
||||
import { Document, DocumentAccessor, DocumentBody, QueryListOption } from "../model/doc";
|
||||
import { TagAccessor } from "../model/tag";
|
||||
import { createKnexTagController } from "./tag";
|
||||
|
||||
export type DBTagContentRelation = {
|
||||
doc_id: number;
|
||||
tag_name: string;
|
||||
};
|
||||
|
||||
class KnexDocumentAccessor implements DocumentAccessor {
|
||||
knex: Knex;
|
||||
tagController: TagAccessor;
|
||||
constructor(knex: Knex) {
|
||||
this.knex = knex;
|
||||
this.tagController = createKnexTagController(knex);
|
||||
}
|
||||
async search(search_word: string): Promise<Document[]> {
|
||||
throw new Error("Method not implemented.");
|
||||
const sw = `%${search_word}%`;
|
||||
const docs = await this.knex.select("*").from("document").where("title", "like", sw);
|
||||
return docs;
|
||||
}
|
||||
async addList(content_list: DocumentBody[]): Promise<number[]> {
|
||||
return await this.knex.transaction(async (trx) => {
|
||||
// add tags
|
||||
const tagCollected = new Set<string>();
|
||||
content_list
|
||||
.map((x) => x.tags)
|
||||
.forEach((x) => {
|
||||
x.forEach((x) => {
|
||||
tagCollected.add(x);
|
||||
});
|
||||
});
|
||||
const tagCollectPromiseList = [];
|
||||
const tagController = createKnexTagController(trx);
|
||||
for (const it of tagCollected) {
|
||||
const p = tagController.addTag({ name: it });
|
||||
tagCollectPromiseList.push(p);
|
||||
}
|
||||
await Promise.all(tagCollectPromiseList);
|
||||
// add for each contents
|
||||
const ret = [];
|
||||
for (const content of content_list) {
|
||||
const { tags, additional, ...rest } = content;
|
||||
const id_lst = await trx
|
||||
.insert({
|
||||
additional: JSON.stringify(additional),
|
||||
created_at: Date.now(),
|
||||
...rest,
|
||||
})
|
||||
.into("document");
|
||||
const id = id_lst[0];
|
||||
if (tags.length > 0) {
|
||||
await trx
|
||||
.insert(
|
||||
tags.map((y) => ({
|
||||
doc_id: id,
|
||||
tag_name: y,
|
||||
})),
|
||||
)
|
||||
.into("doc_tag_relation");
|
||||
}
|
||||
ret.push(id);
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
}
|
||||
async add(c: DocumentBody) {
|
||||
const { tags, additional, ...rest } = c;
|
||||
const id_lst = await this.knex
|
||||
.insert({
|
||||
additional: JSON.stringify(additional),
|
||||
created_at: Date.now(),
|
||||
...rest,
|
||||
})
|
||||
.into("document");
|
||||
const id = id_lst[0];
|
||||
for (const it of tags) {
|
||||
this.tagController.addTag({ name: it });
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
await this.knex
|
||||
.insert<DBTagContentRelation>(tags.map((x) => ({ doc_id: id, tag_name: x })))
|
||||
.into("doc_tag_relation");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
async del(id: number) {
|
||||
if ((await this.findById(id)) !== undefined) {
|
||||
await this.knex.delete().from("doc_tag_relation").where({ doc_id: id });
|
||||
await this.knex.delete().from("document").where({ id: id });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async findById(id: number, tagload?: boolean): Promise<Document | undefined> {
|
||||
const s = await this.knex.select("*").from("document").where({ id: id });
|
||||
if (s.length === 0) return undefined;
|
||||
const first = s[0];
|
||||
let ret_tags: string[] = [];
|
||||
if (tagload === true) {
|
||||
const tags: DBTagContentRelation[] = await this.knex
|
||||
.select("*")
|
||||
.from("doc_tag_relation")
|
||||
.where({ doc_id: first.id });
|
||||
ret_tags = tags.map((x) => x.tag_name);
|
||||
}
|
||||
return {
|
||||
...first,
|
||||
tags: ret_tags,
|
||||
additional: first.additional !== null ? JSON.parse(first.additional) : {},
|
||||
};
|
||||
}
|
||||
async findDeleted(content_type: string) {
|
||||
const s = await this.knex
|
||||
.select("*")
|
||||
.where({ content_type: content_type })
|
||||
.whereNotNull("update_at")
|
||||
.from("document");
|
||||
return s.map((x) => ({
|
||||
...x,
|
||||
tags: [],
|
||||
additional: {},
|
||||
}));
|
||||
}
|
||||
async findList(option?: QueryListOption) {
|
||||
option = option ?? {};
|
||||
const allow_tag = option.allow_tag ?? [];
|
||||
const eager_loading = option.eager_loading ?? true;
|
||||
const limit = option.limit ?? 20;
|
||||
const use_offset = option.use_offset ?? false;
|
||||
const offset = option.offset ?? 0;
|
||||
const word = option.word;
|
||||
const content_type = option.content_type;
|
||||
const cursor = option.cursor;
|
||||
|
||||
const buildquery = () => {
|
||||
let query = this.knex.select("document.*");
|
||||
if (allow_tag.length > 0) {
|
||||
query = query.from("doc_tag_relation as tags_0");
|
||||
query = query.where("tags_0.tag_name", "=", allow_tag[0]);
|
||||
for (let index = 1; index < allow_tag.length; index++) {
|
||||
const element = allow_tag[index];
|
||||
query = query.innerJoin(`doc_tag_relation as tags_${index}`, `tags_${index}.doc_id`, "tags_0.doc_id");
|
||||
query = query.where(`tags_${index}.tag_name`, "=", element);
|
||||
}
|
||||
query = query.innerJoin("document", "tags_0.doc_id", "document.id");
|
||||
} else {
|
||||
query = query.from("document");
|
||||
}
|
||||
if (word !== undefined) {
|
||||
// don't worry about sql injection.
|
||||
query = query.where("title", "like", `%${word}%`);
|
||||
}
|
||||
if (content_type !== undefined) {
|
||||
query = query.where("content_type", "=", content_type);
|
||||
}
|
||||
if (use_offset) {
|
||||
query = query.offset(offset);
|
||||
} else {
|
||||
if (cursor !== undefined) {
|
||||
query = query.where("id", "<", cursor);
|
||||
}
|
||||
}
|
||||
query = query.limit(limit);
|
||||
query = query.orderBy("id", "desc");
|
||||
return query;
|
||||
};
|
||||
let query = buildquery();
|
||||
// console.log(query.toSQL());
|
||||
let result: Document[] = await query;
|
||||
for (let i of result) {
|
||||
i.additional = JSON.parse(i.additional as unknown as string);
|
||||
}
|
||||
if (eager_loading) {
|
||||
let idmap: { [index: number]: Document } = {};
|
||||
for (const r of result) {
|
||||
idmap[r.id] = r;
|
||||
r.tags = [];
|
||||
}
|
||||
let subquery = buildquery();
|
||||
let tagquery = this.knex
|
||||
.select("id", "doc_tag_relation.tag_name")
|
||||
.from(subquery)
|
||||
.innerJoin("doc_tag_relation", "doc_tag_relation.doc_id", "id");
|
||||
// console.log(tagquery.toSQL());
|
||||
let tagresult: { id: number; tag_name: string }[] = await tagquery;
|
||||
for (const { id, tag_name } of tagresult) {
|
||||
idmap[id].tags.push(tag_name);
|
||||
}
|
||||
} else {
|
||||
result.forEach((v) => {
|
||||
v.tags = [];
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async findByPath(path: string, filename?: string): Promise<Document[]> {
|
||||
const e = filename == undefined ? {} : { filename: filename };
|
||||
const results = await this.knex
|
||||
.select("*")
|
||||
.from("document")
|
||||
.where({ basepath: path, ...e });
|
||||
return results.map((x) => ({
|
||||
...x,
|
||||
tags: [],
|
||||
additional: {},
|
||||
}));
|
||||
}
|
||||
async update(c: Partial<Document> & { id: number }) {
|
||||
const { id, tags, ...rest } = c;
|
||||
if ((await this.findById(id)) !== undefined) {
|
||||
await this.knex.update(rest).where({ id: id }).from("document");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async addTag(c: Document, tag_name: string) {
|
||||
if (c.tags.includes(tag_name)) return false;
|
||||
this.tagController.addTag({ name: tag_name });
|
||||
await this.knex.insert<DBTagContentRelation>({ tag_name: tag_name, doc_id: c.id }).into("doc_tag_relation");
|
||||
c.tags.push(tag_name);
|
||||
return true;
|
||||
}
|
||||
async delTag(c: Document, tag_name: string) {
|
||||
if (c.tags.includes(tag_name)) return false;
|
||||
await this.knex.delete().where({ tag_name: tag_name, doc_id: c.id }).from("doc_tag_relation");
|
||||
c.tags.push(tag_name);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
export const createKnexDocumentAccessor = (knex: Knex): DocumentAccessor => {
|
||||
return new KnexDocumentAccessor(knex);
|
||||
};
|
61
packages/server/src/db/tag.ts
Normal file
61
packages/server/src/db/tag.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { Knex } from "knex";
|
||||
import { Tag, TagAccessor, TagCount } from "../model/tag";
|
||||
import { DBTagContentRelation } from "./doc";
|
||||
|
||||
type DBTags = {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
class KnexTagAccessor implements TagAccessor {
|
||||
knex: Knex<DBTags>;
|
||||
constructor(knex: Knex) {
|
||||
this.knex = knex;
|
||||
}
|
||||
async getAllTagCount(): Promise<TagCount[]> {
|
||||
const result = await this.knex<DBTagContentRelation>("doc_tag_relation")
|
||||
.select("tag_name")
|
||||
.count("*", { as: "occurs" })
|
||||
.groupBy<TagCount[]>("tag_name");
|
||||
return result;
|
||||
}
|
||||
async getAllTagList(onlyname?: boolean) {
|
||||
onlyname = onlyname ?? false;
|
||||
const t: DBTags[] = await this.knex.select(onlyname ? "*" : "name").from("tags");
|
||||
return t;
|
||||
}
|
||||
async getTagByName(name: string) {
|
||||
const t: DBTags[] = await this.knex.select("*").from("tags").where({ name: name });
|
||||
if (t.length === 0) return undefined;
|
||||
return t[0];
|
||||
}
|
||||
async addTag(tag: Tag) {
|
||||
if ((await this.getTagByName(tag.name)) === undefined) {
|
||||
await this.knex
|
||||
.insert<DBTags>({
|
||||
name: tag.name,
|
||||
description: tag.description === undefined ? "" : tag.description,
|
||||
})
|
||||
.into("tags");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async delTag(name: string) {
|
||||
if ((await this.getTagByName(name)) !== undefined) {
|
||||
await this.knex.delete().where({ name: name }).from("tags");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async updateTag(name: string, desc: string) {
|
||||
if ((await this.getTagByName(name)) !== undefined) {
|
||||
await this.knex.update({ description: desc }).where({ name: name }).from("tags");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export const createKnexTagController = (knex: Knex): TagAccessor => {
|
||||
return new KnexTagAccessor(knex);
|
||||
};
|
88
packages/server/src/db/user.ts
Normal file
88
packages/server/src/db/user.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { Knex } from "knex";
|
||||
import { IUser, Password, UserAccessor, UserCreateInput } from "../model/user";
|
||||
|
||||
type PermissionTable = {
|
||||
username: string;
|
||||
name: string;
|
||||
};
|
||||
type DBUser = {
|
||||
username: string;
|
||||
password_hash: string;
|
||||
password_salt: string;
|
||||
};
|
||||
class KnexUser implements IUser {
|
||||
private knex: Knex;
|
||||
readonly username: string;
|
||||
readonly password: Password;
|
||||
|
||||
constructor(username: string, pw: Password, knex: Knex) {
|
||||
this.username = username;
|
||||
this.password = pw;
|
||||
this.knex = knex;
|
||||
}
|
||||
async reset_password(password: string) {
|
||||
this.password.set_password(password);
|
||||
await this.knex
|
||||
.from("users")
|
||||
.where({ username: this.username })
|
||||
.update({ password_hash: this.password.hash, password_salt: this.password.salt });
|
||||
}
|
||||
async get_permissions() {
|
||||
let b = (await this.knex.select("*").from("permissions").where({ username: this.username })) as PermissionTable[];
|
||||
return b.map((x) => x.name);
|
||||
}
|
||||
async add(name: string) {
|
||||
if (!(await this.get_permissions()).includes(name)) {
|
||||
const r = await this.knex
|
||||
.insert({
|
||||
username: this.username,
|
||||
name: name,
|
||||
})
|
||||
.into("permissions");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async remove(name: string) {
|
||||
const r = await this.knex
|
||||
.from("permissions")
|
||||
.where({
|
||||
username: this.username,
|
||||
name: name,
|
||||
})
|
||||
.delete();
|
||||
return r !== 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const createKnexUserController = (knex: Knex): UserAccessor => {
|
||||
const createUserKnex = async (input: UserCreateInput) => {
|
||||
if (undefined !== (await findUserKenx(input.username))) {
|
||||
return undefined;
|
||||
}
|
||||
const user = new KnexUser(input.username, new Password(input.password), knex);
|
||||
await knex
|
||||
.insert<DBUser>({
|
||||
username: user.username,
|
||||
password_hash: user.password.hash,
|
||||
password_salt: user.password.salt,
|
||||
})
|
||||
.into("users");
|
||||
return user;
|
||||
};
|
||||
const findUserKenx = async (id: string) => {
|
||||
let user: DBUser[] = await knex.select("*").from("users").where({ username: id });
|
||||
if (user.length == 0) return undefined;
|
||||
const first = user[0];
|
||||
return new KnexUser(first.username, new Password({ hash: first.password_hash, salt: first.password_salt }), knex);
|
||||
};
|
||||
const delUserKnex = async (id: string) => {
|
||||
let r = await knex.delete().from("users").where({ username: id });
|
||||
return r === 0;
|
||||
};
|
||||
return {
|
||||
createUser: createUserKnex,
|
||||
findUser: findUserKenx,
|
||||
delUser: delUserKnex,
|
||||
};
|
||||
};
|
121
packages/server/src/diff/content_handler.ts
Normal file
121
packages/server/src/diff/content_handler.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { basename, dirname, join as pathjoin } from "path";
|
||||
import { ContentFile, createContentFile } from "../content/mod";
|
||||
import { Document, DocumentAccessor } from "../model/mod";
|
||||
import { ContentList } from "./content_list";
|
||||
import { IDiffWatcher } from "./watcher";
|
||||
|
||||
// refactoring needed.
|
||||
export class ContentDiffHandler {
|
||||
/** content file list waiting to add */
|
||||
waiting_list: ContentList;
|
||||
/** deleted contents */
|
||||
tombstone: Map<string, Document>; // hash, contentfile
|
||||
doc_cntr: DocumentAccessor;
|
||||
/** content type of handle */
|
||||
content_type: string;
|
||||
constructor(cntr: DocumentAccessor, content_type: string) {
|
||||
this.waiting_list = new ContentList();
|
||||
this.tombstone = new Map<string, Document>();
|
||||
this.doc_cntr = cntr;
|
||||
this.content_type = content_type;
|
||||
}
|
||||
async setup() {
|
||||
const deleted = await this.doc_cntr.findDeleted(this.content_type);
|
||||
for (const it of deleted) {
|
||||
this.tombstone.set(it.content_hash, it);
|
||||
}
|
||||
}
|
||||
register(diff: IDiffWatcher) {
|
||||
diff
|
||||
.on("create", (path) => this.OnCreated(path))
|
||||
.on("delete", (path) => this.OnDeleted(path))
|
||||
.on("change", (prev, cur) => this.OnChanged(prev, cur));
|
||||
}
|
||||
private async OnDeleted(cpath: string) {
|
||||
const basepath = dirname(cpath);
|
||||
const filename = basename(cpath);
|
||||
console.log("deleted ", cpath);
|
||||
// if it wait to add, delete it from waiting list.
|
||||
if (this.waiting_list.hasByPath(cpath)) {
|
||||
this.waiting_list.deleteByPath(cpath);
|
||||
return;
|
||||
}
|
||||
const dbc = await this.doc_cntr.findByPath(basepath, filename);
|
||||
// when there is no related content in db, ignore.
|
||||
if (dbc.length === 0) {
|
||||
console.log("its not in waiting_list and db!!!: ", cpath);
|
||||
return;
|
||||
}
|
||||
const content_hash = dbc[0].content_hash;
|
||||
// When a path is changed, it takes into account when the
|
||||
// creation event occurs first and the deletion occurs, not
|
||||
// the change event.
|
||||
const cf = this.waiting_list.getByHash(content_hash);
|
||||
if (cf) {
|
||||
// if a path is changed, update the changed path.
|
||||
console.log("update path from", cpath, "to", cf.path);
|
||||
const newFilename = basename(cf.path);
|
||||
const newBasepath = dirname(cf.path);
|
||||
this.waiting_list.deleteByHash(content_hash);
|
||||
await this.doc_cntr.update({
|
||||
id: dbc[0].id,
|
||||
deleted_at: null,
|
||||
filename: newFilename,
|
||||
basepath: newBasepath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// invalidate db and add it to tombstone.
|
||||
await this.doc_cntr.update({
|
||||
id: dbc[0].id,
|
||||
deleted_at: Date.now(),
|
||||
});
|
||||
this.tombstone.set(dbc[0].content_hash, dbc[0]);
|
||||
}
|
||||
private async OnCreated(cpath: string) {
|
||||
const basepath = dirname(cpath);
|
||||
const filename = basename(cpath);
|
||||
console.log("createContentFile", cpath);
|
||||
const content = createContentFile(this.content_type, cpath);
|
||||
const hash = await content.getHash();
|
||||
const c = this.tombstone.get(hash);
|
||||
if (c !== undefined) {
|
||||
await this.doc_cntr.update({
|
||||
id: c.id,
|
||||
deleted_at: null,
|
||||
filename: filename,
|
||||
basepath: basepath,
|
||||
});
|
||||
}
|
||||
if (this.waiting_list.hasByHash(hash)) {
|
||||
console.log("Hash Conflict!!!");
|
||||
}
|
||||
this.waiting_list.set(content);
|
||||
}
|
||||
private async OnChanged(prev_path: string, cur_path: string) {
|
||||
const prev_basepath = dirname(prev_path);
|
||||
const prev_filename = basename(prev_path);
|
||||
const cur_basepath = dirname(cur_path);
|
||||
const cur_filename = basename(cur_path);
|
||||
console.log("modify", cur_path, "from", prev_path);
|
||||
const c = this.waiting_list.getByPath(prev_path);
|
||||
if (c !== undefined) {
|
||||
await this.waiting_list.delete(c);
|
||||
const content = createContentFile(this.content_type, cur_path);
|
||||
await this.waiting_list.set(content);
|
||||
return;
|
||||
}
|
||||
const doc = await this.doc_cntr.findByPath(prev_basepath, prev_filename);
|
||||
|
||||
if (doc.length === 0) {
|
||||
await this.OnCreated(cur_path);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.doc_cntr.update({
|
||||
...doc[0],
|
||||
basepath: cur_basepath,
|
||||
filename: cur_filename,
|
||||
});
|
||||
}
|
||||
}
|
59
packages/server/src/diff/content_list.ts
Normal file
59
packages/server/src/diff/content_list.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { ContentFile } from "../content/mod";
|
||||
|
||||
export class ContentList {
|
||||
/** path map */
|
||||
private cl: Map<string, ContentFile>;
|
||||
/** hash map */
|
||||
private hl: Map<string, ContentFile>;
|
||||
|
||||
constructor() {
|
||||
this.cl = new Map();
|
||||
this.hl = new Map();
|
||||
}
|
||||
hasByHash(s: string) {
|
||||
return this.hl.has(s);
|
||||
}
|
||||
hasByPath(p: string) {
|
||||
return this.cl.has(p);
|
||||
}
|
||||
getByHash(s: string) {
|
||||
return this.hl.get(s);
|
||||
}
|
||||
getByPath(p: string) {
|
||||
return this.cl.get(p);
|
||||
}
|
||||
async set(c: ContentFile) {
|
||||
const path = c.path;
|
||||
const hash = await c.getHash();
|
||||
this.cl.set(path, c);
|
||||
this.hl.set(hash, c);
|
||||
}
|
||||
/** delete content file */
|
||||
async delete(c: ContentFile) {
|
||||
const hash = await c.getHash();
|
||||
let r = true;
|
||||
r = this.cl.delete(c.path) && r;
|
||||
r = this.hl.delete(hash) && r;
|
||||
return r;
|
||||
}
|
||||
async deleteByPath(p: string) {
|
||||
const o = this.getByPath(p);
|
||||
if (o === undefined) return false;
|
||||
return await this.delete(o);
|
||||
}
|
||||
deleteByHash(s: string) {
|
||||
const o = this.getByHash(s);
|
||||
if (o === undefined) return false;
|
||||
let r = true;
|
||||
r = this.cl.delete(o.path) && r;
|
||||
r = this.hl.delete(s) && r;
|
||||
return r;
|
||||
}
|
||||
clear() {
|
||||
this.cl.clear();
|
||||
this.hl.clear();
|
||||
}
|
||||
getAll() {
|
||||
return [...this.cl.values()];
|
||||
}
|
||||
}
|
45
packages/server/src/diff/diff.ts
Normal file
45
packages/server/src/diff/diff.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import asyncPool from "tiny-async-pool";
|
||||
import { DocumentAccessor } from "../model/doc";
|
||||
import { ContentDiffHandler } from "./content_handler";
|
||||
import { IDiffWatcher } from "./watcher";
|
||||
|
||||
export class DiffManager {
|
||||
watching: { [content_type: string]: ContentDiffHandler };
|
||||
doc_cntr: DocumentAccessor;
|
||||
constructor(contorller: DocumentAccessor) {
|
||||
this.watching = {};
|
||||
this.doc_cntr = contorller;
|
||||
}
|
||||
async register(content_type: string, watcher: IDiffWatcher) {
|
||||
if (this.watching[content_type] === undefined) {
|
||||
this.watching[content_type] = new ContentDiffHandler(this.doc_cntr, content_type);
|
||||
}
|
||||
this.watching[content_type].register(watcher);
|
||||
await watcher.setup(this.doc_cntr);
|
||||
}
|
||||
async commit(type: string, path: string) {
|
||||
const list = this.watching[type].waiting_list;
|
||||
const c = list.getByPath(path);
|
||||
if (c === undefined) {
|
||||
throw new Error("path is not exist");
|
||||
}
|
||||
await list.delete(c);
|
||||
const body = await c.createDocumentBody();
|
||||
const id = await this.doc_cntr.add(body);
|
||||
return id;
|
||||
}
|
||||
async commitAll(type: string) {
|
||||
const list = this.watching[type].waiting_list;
|
||||
const contentFiles = list.getAll();
|
||||
list.clear();
|
||||
const bodies = await asyncPool(30, contentFiles, async (x) => await x.createDocumentBody());
|
||||
const ids = await this.doc_cntr.addList(bodies);
|
||||
return ids;
|
||||
}
|
||||
getAdded() {
|
||||
return Object.keys(this.watching).map((x) => ({
|
||||
type: x,
|
||||
value: this.watching[x].waiting_list.getAll(),
|
||||
}));
|
||||
}
|
||||
}
|
83
packages/server/src/diff/router.ts
Normal file
83
packages/server/src/diff/router.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import Koa from "koa";
|
||||
import Router from "koa-router";
|
||||
import { ContentFile } from "../content/mod";
|
||||
import { AdminOnlyMiddleware } from "../permission/permission";
|
||||
import { sendError } from "../route/error_handler";
|
||||
import { DiffManager } from "./diff";
|
||||
|
||||
function content_file_to_return(x: ContentFile) {
|
||||
return { path: x.path, type: x.type };
|
||||
}
|
||||
|
||||
export const getAdded = (diffmgr: DiffManager) => (ctx: Koa.Context, next: Koa.Next) => {
|
||||
const ret = diffmgr.getAdded();
|
||||
ctx.body = ret.map((x) => ({
|
||||
type: x.type,
|
||||
value: x.value.map((x) => ({ path: x.path, type: x.type })),
|
||||
}));
|
||||
ctx.type = "json";
|
||||
};
|
||||
|
||||
type PostAddedBody = {
|
||||
type: string;
|
||||
path: string;
|
||||
}[];
|
||||
|
||||
function checkPostAddedBody(body: any): body is PostAddedBody {
|
||||
if (body instanceof Array) {
|
||||
return body.map((x) => "type" in x && "path" in x).every((x) => x);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const postAdded = (diffmgr: DiffManager) => async (ctx: Router.IRouterContext, next: Koa.Next) => {
|
||||
const reqbody = ctx.request.body;
|
||||
if (!checkPostAddedBody(reqbody)) {
|
||||
sendError(400, "format exception");
|
||||
return;
|
||||
}
|
||||
const allWork = reqbody.map((op) => diffmgr.commit(op.type, op.path));
|
||||
const results = await Promise.all(allWork);
|
||||
ctx.body = {
|
||||
ok: true,
|
||||
docs: results,
|
||||
};
|
||||
ctx.type = "json";
|
||||
};
|
||||
export const postAddedAll = (diffmgr: DiffManager) => async (ctx: Router.IRouterContext, next: Koa.Next) => {
|
||||
if (!ctx.is("json")) {
|
||||
sendError(400, "format exception");
|
||||
return;
|
||||
}
|
||||
const reqbody = ctx.request.body as Record<string, unknown>;
|
||||
if (!("type" in reqbody)) {
|
||||
sendError(400, 'format exception: there is no "type"');
|
||||
return;
|
||||
}
|
||||
const t = reqbody["type"];
|
||||
if (typeof t !== "string") {
|
||||
sendError(400, 'format exception: invalid type of "type"');
|
||||
return;
|
||||
}
|
||||
await diffmgr.commitAll(t);
|
||||
ctx.body = {
|
||||
ok: true,
|
||||
};
|
||||
ctx.type = "json";
|
||||
};
|
||||
/*
|
||||
export const getNotWatched = (diffmgr : DiffManager)=> (ctx:Router.IRouterContext,next:Koa.Next)=>{
|
||||
ctx.body = {
|
||||
added: diffmgr.added.map(content_file_to_return),
|
||||
deleted: diffmgr.deleted.map(content_file_to_return),
|
||||
};
|
||||
ctx.type = 'json';
|
||||
}*/
|
||||
|
||||
export function createDiffRouter(diffmgr: DiffManager) {
|
||||
const ret = new Router();
|
||||
ret.get("/list", AdminOnlyMiddleware, getAdded(diffmgr));
|
||||
ret.post("/commit", AdminOnlyMiddleware, postAdded(diffmgr));
|
||||
ret.post("/commitall", AdminOnlyMiddleware, postAddedAll(diffmgr));
|
||||
return ret;
|
||||
}
|
25
packages/server/src/diff/watcher.ts
Normal file
25
packages/server/src/diff/watcher.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import event from "events";
|
||||
import { FSWatcher, watch } from "fs";
|
||||
import { promises } from "fs";
|
||||
import { join } from "path";
|
||||
import { DocumentAccessor } from "../model/doc";
|
||||
|
||||
const readdir = promises.readdir;
|
||||
|
||||
export interface DiffWatcherEvent {
|
||||
create: (path: string) => void;
|
||||
delete: (path: string) => void;
|
||||
change: (prev_path: string, cur_path: string) => void;
|
||||
}
|
||||
|
||||
export interface IDiffWatcher extends event.EventEmitter {
|
||||
on<U extends keyof DiffWatcherEvent>(event: U, listener: DiffWatcherEvent[U]): this;
|
||||
emit<U extends keyof DiffWatcherEvent>(event: U, ...arg: Parameters<DiffWatcherEvent[U]>): boolean;
|
||||
setup(cntr: DocumentAccessor): Promise<void>;
|
||||
}
|
||||
|
||||
export function linkWatcher(fromWatcher: IDiffWatcher, toWatcher: IDiffWatcher) {
|
||||
fromWatcher.on("create", (p) => toWatcher.emit("create", p));
|
||||
fromWatcher.on("delete", (p) => toWatcher.emit("delete", p));
|
||||
fromWatcher.on("change", (p, c) => toWatcher.emit("change", p, c));
|
||||
}
|
12
packages/server/src/diff/watcher/ComicConfig.schema.json
Normal file
12
packages/server/src/diff/watcher/ComicConfig.schema.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$ref": "#/definitions/ComicConfig",
|
||||
"definitions": {
|
||||
"ComicConfig": {
|
||||
"type": "object",
|
||||
"properties": { "watch": { "type": "array", "items": { "type": "string" } }, "$schema": { "type": "string" } },
|
||||
"required": ["watch"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
@ -12,5 +12,5 @@ const createComicWatcherBase = (path: string) => {
|
||||
export const createComicWatcher = () => {
|
||||
const file = ComicConfig.get_config_file();
|
||||
console.log(`register comic ${file.watch.join(",")}`);
|
||||
return new WatcherCompositer(file.watch.map(path => createComicWatcherBase(path)));
|
||||
return new WatcherCompositer(file.watch.map((path) => createComicWatcherBase(path)));
|
||||
};
|
44
packages/server/src/diff/watcher/common_watcher.ts
Normal file
44
packages/server/src/diff/watcher/common_watcher.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import event from "events";
|
||||
import { FSWatcher, promises, watch } from "fs";
|
||||
import { join } from "path";
|
||||
import { DocumentAccessor } from "../../model/doc";
|
||||
import { DiffWatcherEvent, IDiffWatcher } from "../watcher";
|
||||
import { setupHelp } from "./util";
|
||||
|
||||
const { readdir } = promises;
|
||||
|
||||
export class CommonDiffWatcher extends event.EventEmitter implements IDiffWatcher {
|
||||
on<U extends keyof DiffWatcherEvent>(event: U, listener: DiffWatcherEvent[U]): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
emit<U extends keyof DiffWatcherEvent>(event: U, ...arg: Parameters<DiffWatcherEvent[U]>): boolean {
|
||||
return super.emit(event, ...arg);
|
||||
}
|
||||
private _path: string;
|
||||
private _watcher: FSWatcher;
|
||||
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this._path = path;
|
||||
this._watcher = watch(this._path, { persistent: true, recursive: false }, async (eventType, filename) => {
|
||||
if (eventType === "rename") {
|
||||
const cur = await readdir(this._path);
|
||||
// add
|
||||
if (cur.includes(filename)) {
|
||||
this.emit("create", join(this.path, filename));
|
||||
} else {
|
||||
this.emit("delete", join(this.path, filename));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async setup(cntr: DocumentAccessor): Promise<void> {
|
||||
await setupHelp(this, this.path, cntr);
|
||||
}
|
||||
public get path() {
|
||||
return this._path;
|
||||
}
|
||||
watchClose() {
|
||||
this._watcher.close();
|
||||
}
|
||||
}
|
23
packages/server/src/diff/watcher/compositer.ts
Normal file
23
packages/server/src/diff/watcher/compositer.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { EventEmitter } from "events";
|
||||
import { DocumentAccessor } from "../../model/doc";
|
||||
import { DiffWatcherEvent, IDiffWatcher, linkWatcher } from "../watcher";
|
||||
|
||||
export class WatcherCompositer extends EventEmitter implements IDiffWatcher {
|
||||
refWatchers: IDiffWatcher[];
|
||||
on<U extends keyof DiffWatcherEvent>(event: U, listener: DiffWatcherEvent[U]): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
emit<U extends keyof DiffWatcherEvent>(event: U, ...arg: Parameters<DiffWatcherEvent[U]>): boolean {
|
||||
return super.emit(event, ...arg);
|
||||
}
|
||||
constructor(refWatchers: IDiffWatcher[]) {
|
||||
super();
|
||||
this.refWatchers = refWatchers;
|
||||
for (const refWatcher of this.refWatchers) {
|
||||
linkWatcher(refWatcher, this);
|
||||
}
|
||||
}
|
||||
async setup(cntr: DocumentAccessor): Promise<void> {
|
||||
await Promise.all(this.refWatchers.map((x) => x.setup(cntr)));
|
||||
}
|
||||
}
|
68
packages/server/src/diff/watcher/recursive_watcher.ts
Normal file
68
packages/server/src/diff/watcher/recursive_watcher.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { FSWatcher, watch } from "chokidar";
|
||||
import { EventEmitter } from "events";
|
||||
import { join } from "path";
|
||||
import { DocumentAccessor } from "../../model/doc";
|
||||
import { DiffWatcherEvent, IDiffWatcher } from "../watcher";
|
||||
import { setupHelp, setupRecursive } from "./util";
|
||||
|
||||
type RecursiveWatcherOption = {
|
||||
/** @default true */
|
||||
watchFile?: boolean;
|
||||
/** @default false */
|
||||
watchDir?: boolean;
|
||||
};
|
||||
|
||||
export class RecursiveWatcher extends EventEmitter implements IDiffWatcher {
|
||||
on<U extends keyof DiffWatcherEvent>(event: U, listener: DiffWatcherEvent[U]): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
emit<U extends keyof DiffWatcherEvent>(event: U, ...arg: Parameters<DiffWatcherEvent[U]>): boolean {
|
||||
return super.emit(event, ...arg);
|
||||
}
|
||||
readonly path: string;
|
||||
private watcher: FSWatcher;
|
||||
|
||||
constructor(
|
||||
path: string,
|
||||
option: RecursiveWatcherOption = {
|
||||
watchDir: false,
|
||||
watchFile: true,
|
||||
},
|
||||
) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.watcher = watch(path, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
depth: 100,
|
||||
});
|
||||
option.watchFile ??= true;
|
||||
if (option.watchFile) {
|
||||
this.watcher
|
||||
.on("add", (path) => {
|
||||
const cpath = path;
|
||||
// console.log("add ", cpath);
|
||||
this.emit("create", cpath);
|
||||
})
|
||||
.on("unlink", (path) => {
|
||||
const cpath = path;
|
||||
// console.log("unlink ", cpath);
|
||||
this.emit("delete", cpath);
|
||||
});
|
||||
}
|
||||
if (option.watchDir) {
|
||||
this.watcher
|
||||
.on("addDir", (path) => {
|
||||
const cpath = path;
|
||||
this.emit("create", cpath);
|
||||
})
|
||||
.on("unlinkDir", (path) => {
|
||||
const cpath = path;
|
||||
this.emit("delete", cpath);
|
||||
});
|
||||
}
|
||||
}
|
||||
async setup(cntr: DocumentAccessor): Promise<void> {
|
||||
await setupRecursive(this, this.path, cntr);
|
||||
}
|
||||
}
|
38
packages/server/src/diff/watcher/util.ts
Normal file
38
packages/server/src/diff/watcher/util.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { EventEmitter } from "events";
|
||||
import { promises } from "fs";
|
||||
import { join } from "path";
|
||||
const { readdir } = promises;
|
||||
import { DocumentAccessor } from "../../model/doc";
|
||||
import { IDiffWatcher } from "../watcher";
|
||||
|
||||
function setupCommon(watcher: IDiffWatcher, basepath: string, initial_filenames: string[], cur: string[]) {
|
||||
// Todo : reduce O(nm) to O(n+m) using hash map.
|
||||
let added = cur.filter((x) => !initial_filenames.includes(x));
|
||||
let deleted = initial_filenames.filter((x) => !cur.includes(x));
|
||||
for (const it of added) {
|
||||
const cpath = join(basepath, it);
|
||||
watcher.emit("create", cpath);
|
||||
}
|
||||
for (const it of deleted) {
|
||||
const cpath = join(basepath, it);
|
||||
watcher.emit("delete", cpath);
|
||||
}
|
||||
}
|
||||
export async function setupHelp(watcher: IDiffWatcher, basepath: string, cntr: DocumentAccessor) {
|
||||
const initial_document = await cntr.findByPath(basepath);
|
||||
const initial_filenames = initial_document.map((x) => x.filename);
|
||||
const cur = await readdir(basepath);
|
||||
setupCommon(watcher, basepath, initial_filenames, cur);
|
||||
}
|
||||
export async function setupRecursive(watcher: IDiffWatcher, basepath: string, cntr: DocumentAccessor) {
|
||||
const initial_document = await cntr.findByPath(basepath);
|
||||
const initial_filenames = initial_document.map((x) => x.filename);
|
||||
const cur = await readdir(basepath, { withFileTypes: true });
|
||||
setupCommon(
|
||||
watcher,
|
||||
basepath,
|
||||
initial_filenames,
|
||||
cur.map((x) => x.name),
|
||||
);
|
||||
await Promise.all([cur.filter((x) => x.isDirectory()).map((x) => setupHelp(watcher, join(basepath, x.name), cntr))]);
|
||||
}
|
46
packages/server/src/diff/watcher/watcher_filter.ts
Normal file
46
packages/server/src/diff/watcher/watcher_filter.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { EventEmitter } from "events";
|
||||
import { DocumentAccessor } from "../../model/doc";
|
||||
import { DiffWatcherEvent, IDiffWatcher, linkWatcher } from "../watcher";
|
||||
|
||||
export class WatcherFilter extends EventEmitter implements IDiffWatcher {
|
||||
refWatcher: IDiffWatcher;
|
||||
filter: (filename: string) => boolean;
|
||||
on<U extends keyof DiffWatcherEvent>(event: U, listener: DiffWatcherEvent[U]): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
/**
|
||||
* emit event
|
||||
* @param event
|
||||
* @param arg
|
||||
* @returns `true` if the event had listeners, `false` otherwise.
|
||||
*/
|
||||
emit<U extends keyof DiffWatcherEvent>(event: U, ...arg: Parameters<DiffWatcherEvent[U]>): boolean {
|
||||
if (event === "change") {
|
||||
const prev = arg[0];
|
||||
const cur = arg[1] as string;
|
||||
if (this.filter(prev)) {
|
||||
if (this.filter(cur)) {
|
||||
return super.emit("change", prev, cur);
|
||||
} else {
|
||||
return super.emit("delete", cur);
|
||||
}
|
||||
} else {
|
||||
if (this.filter(cur)) {
|
||||
return super.emit("create", cur);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} else if (!this.filter(arg[0])) {
|
||||
return false;
|
||||
} else return super.emit(event, ...arg);
|
||||
}
|
||||
constructor(refWatcher: IDiffWatcher, filter: (filename: string) => boolean) {
|
||||
super();
|
||||
this.refWatcher = refWatcher;
|
||||
this.filter = filter;
|
||||
linkWatcher(refWatcher, this);
|
||||
}
|
||||
setup(cntr: DocumentAccessor): Promise<void> {
|
||||
return this.refWatcher.setup(cntr);
|
||||
}
|
||||
}
|
245
packages/server/src/login.ts
Normal file
245
packages/server/src/login.ts
Normal file
@ -0,0 +1,245 @@
|
||||
import { request } from "http";
|
||||
import { decode, sign, TokenExpiredError, verify } from "jsonwebtoken";
|
||||
import Knex from "knex";
|
||||
import Koa from "koa";
|
||||
import Router from "koa-router";
|
||||
import { createKnexUserController } from "./db/mod";
|
||||
import { IUser, UserAccessor } from "./model/mod";
|
||||
import { sendError } from "./route/error_handler";
|
||||
import { get_setting } from "./SettingConfig";
|
||||
|
||||
type PayloadInfo = {
|
||||
username: string;
|
||||
permission: string[];
|
||||
};
|
||||
|
||||
export type UserState = {
|
||||
user: PayloadInfo;
|
||||
};
|
||||
|
||||
const isUserState = (obj: object | string): obj is PayloadInfo => {
|
||||
if (typeof obj === "string") return false;
|
||||
return "username" in obj && "permission" in obj && (obj as { permission: unknown }).permission instanceof Array;
|
||||
};
|
||||
type RefreshPayloadInfo = { username: string };
|
||||
const isRefreshToken = (obj: object | string): obj is RefreshPayloadInfo => {
|
||||
if (typeof obj === "string") return false;
|
||||
return "username" in obj && typeof (obj as { username: unknown }).username === "string";
|
||||
};
|
||||
|
||||
export const accessTokenName = "access_token";
|
||||
export const refreshTokenName = "refresh_token";
|
||||
const accessExpiredTime = 60 * 60; // 1 hour
|
||||
const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 day;
|
||||
|
||||
export const getAdminAccessTokenValue = () => {
|
||||
const { jwt_secretkey } = get_setting();
|
||||
return publishAccessToken(jwt_secretkey, "admin", [], accessExpiredTime);
|
||||
};
|
||||
export const getAdminRefreshTokenValue = () => {
|
||||
const { jwt_secretkey } = get_setting();
|
||||
return publishRefreshToken(jwt_secretkey, "admin", refreshExpiredTime);
|
||||
};
|
||||
const publishAccessToken = (secretKey: string, username: string, permission: string[], expiredtime: number) => {
|
||||
const payload = sign(
|
||||
{
|
||||
username: username,
|
||||
permission: permission,
|
||||
},
|
||||
secretKey,
|
||||
{ expiresIn: expiredtime },
|
||||
);
|
||||
return payload;
|
||||
};
|
||||
const publishRefreshToken = (secretKey: string, username: string, expiredtime: number) => {
|
||||
const payload = sign({ username: username }, secretKey, { expiresIn: expiredtime });
|
||||
return payload;
|
||||
};
|
||||
function setToken(ctx: Koa.Context, token_name: string, token_payload: string | null, expiredtime: number) {
|
||||
const setting = get_setting();
|
||||
if (token_payload === null && !!!ctx.cookies.get(token_name)) {
|
||||
return;
|
||||
}
|
||||
ctx.cookies.set(token_name, token_payload, {
|
||||
httpOnly: true,
|
||||
secure: setting.secure,
|
||||
sameSite: "strict",
|
||||
expires: new Date(Date.now() + expiredtime * 1000),
|
||||
});
|
||||
}
|
||||
export const createLoginMiddleware = (userController: UserAccessor) => async (ctx: Koa.Context, _next: Koa.Next) => {
|
||||
const setting = get_setting();
|
||||
const secretKey = setting.jwt_secretkey;
|
||||
const body = ctx.request.body;
|
||||
// check format
|
||||
if (typeof body == "string" || !("username" in body) || !("password" in body)) {
|
||||
return sendError(400, "invalid form : username or password is not found in query.");
|
||||
}
|
||||
const username = body["username"];
|
||||
const password = body["password"];
|
||||
// check type
|
||||
if (typeof username !== "string" || typeof password !== "string") {
|
||||
return sendError(400, "invalid form : username or password is not string");
|
||||
}
|
||||
// if admin login is forbidden?
|
||||
if (username === "admin" && setting.forbid_remote_admin_login) {
|
||||
return sendError(403, "forbidden remote admin login");
|
||||
}
|
||||
const user = await userController.findUser(username);
|
||||
// username not exist
|
||||
if (user === undefined) return sendError(401, "not authorized");
|
||||
// password not matched
|
||||
if (!user.password.check_password(password)) {
|
||||
return sendError(401, "not authorized");
|
||||
}
|
||||
// create token
|
||||
const userPermission = await user.get_permissions();
|
||||
const payload = publishAccessToken(secretKey, user.username, userPermission, accessExpiredTime);
|
||||
const payload2 = publishRefreshToken(secretKey, user.username, refreshExpiredTime);
|
||||
setToken(ctx, accessTokenName, payload, accessExpiredTime);
|
||||
setToken(ctx, refreshTokenName, payload2, refreshExpiredTime);
|
||||
ctx.body = {
|
||||
username: user.username,
|
||||
permission: userPermission,
|
||||
accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime,
|
||||
};
|
||||
console.log(`${username} logined`);
|
||||
return;
|
||||
};
|
||||
|
||||
export const LogoutMiddleware = (ctx: Koa.Context, next: Koa.Next) => {
|
||||
const setting = get_setting();
|
||||
ctx.cookies.set(accessTokenName, null);
|
||||
ctx.cookies.set(refreshTokenName, null);
|
||||
ctx.body = {
|
||||
ok: true,
|
||||
username: "",
|
||||
permission: setting.guest,
|
||||
};
|
||||
return;
|
||||
};
|
||||
export const createUserMiddleWare =
|
||||
(userController: UserAccessor) => async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
|
||||
const refreshToken = refreshTokenHandler(userController);
|
||||
const setting = get_setting();
|
||||
const setGuest = async () => {
|
||||
setToken(ctx, accessTokenName, null, 0);
|
||||
setToken(ctx, refreshTokenName, null, 0);
|
||||
ctx.state["user"] = { username: "", permission: setting.guest };
|
||||
return await next();
|
||||
};
|
||||
return await refreshToken(ctx, setGuest, next);
|
||||
};
|
||||
const refreshTokenHandler = (cntr: UserAccessor) => async (ctx: Koa.Context, fail: Koa.Next, next: Koa.Next) => {
|
||||
const accessPayload = ctx.cookies.get(accessTokenName);
|
||||
const setting = get_setting();
|
||||
const secretKey = setting.jwt_secretkey;
|
||||
if (accessPayload == undefined) {
|
||||
return await checkRefreshAndUpdate();
|
||||
}
|
||||
try {
|
||||
const o = verify(accessPayload, secretKey);
|
||||
if (isUserState(o)) {
|
||||
ctx.state.user = o;
|
||||
return await next();
|
||||
} else {
|
||||
console.error("invalid token detected");
|
||||
throw new Error("token form invalid");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof TokenExpiredError) {
|
||||
return await checkRefreshAndUpdate();
|
||||
} else throw e;
|
||||
}
|
||||
async function checkRefreshAndUpdate() {
|
||||
const refreshPayload = ctx.cookies.get(refreshTokenName);
|
||||
if (refreshPayload === undefined) {
|
||||
return await fail(); // refresh token doesn't exist
|
||||
} else {
|
||||
try {
|
||||
const o = verify(refreshPayload, secretKey);
|
||||
if (isRefreshToken(o)) {
|
||||
const user = await cntr.findUser(o.username);
|
||||
if (user === undefined) return await fail(); // already non-existence user
|
||||
const perm = await user.get_permissions();
|
||||
const payload = publishAccessToken(secretKey, user.username, perm, accessExpiredTime);
|
||||
setToken(ctx, accessTokenName, payload, accessExpiredTime);
|
||||
ctx.state.user = { username: o.username, permission: perm };
|
||||
} else {
|
||||
console.error("invalid token detected");
|
||||
throw new Error("token form invalid");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof TokenExpiredError) {
|
||||
// refresh token is expired.
|
||||
return await fail();
|
||||
} else throw e;
|
||||
}
|
||||
}
|
||||
return await next();
|
||||
}
|
||||
};
|
||||
export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => {
|
||||
const handler = refreshTokenHandler(cntr);
|
||||
await handler(ctx, fail, success);
|
||||
async function fail() {
|
||||
const user = ctx.state.user as PayloadInfo;
|
||||
ctx.body = {
|
||||
refresh: false,
|
||||
...user,
|
||||
};
|
||||
ctx.type = "json";
|
||||
}
|
||||
async function success() {
|
||||
const user = ctx.state.user as PayloadInfo;
|
||||
ctx.body = {
|
||||
...user,
|
||||
refresh: true,
|
||||
refreshExpired: Math.floor(Date.now() / 1000 + accessExpiredTime),
|
||||
};
|
||||
ctx.type = "json";
|
||||
}
|
||||
};
|
||||
export const resetPasswordMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => {
|
||||
const body = ctx.request.body;
|
||||
if (typeof body !== "object" || !("username" in body) || !("oldpassword" in body) || !("newpassword" in body)) {
|
||||
return sendError(400, "request body is invalid format");
|
||||
}
|
||||
const username = body["username"];
|
||||
const oldpw = body["oldpassword"];
|
||||
const newpw = body["newpassword"];
|
||||
if (typeof username !== "string" || typeof oldpw !== "string" || typeof newpw !== "string") {
|
||||
return sendError(400, "request body is invalid format");
|
||||
}
|
||||
const user = await cntr.findUser(username);
|
||||
if (user === undefined) {
|
||||
return sendError(403, "not authorized");
|
||||
}
|
||||
if (!user.password.check_password(oldpw)) {
|
||||
return sendError(403, "not authorized");
|
||||
}
|
||||
user.reset_password(newpw);
|
||||
ctx.body = { ok: true };
|
||||
ctx.type = "json";
|
||||
};
|
||||
|
||||
export function createLoginRouter(userController: UserAccessor) {
|
||||
const router = new Router();
|
||||
router.post("/login", createLoginMiddleware(userController));
|
||||
router.post("/logout", LogoutMiddleware);
|
||||
router.post("/refresh", createRefreshTokenMiddleware(userController));
|
||||
router.post("/reset", resetPasswordMiddleware(userController));
|
||||
return router;
|
||||
}
|
||||
|
||||
export const getAdmin = async (cntr: UserAccessor) => {
|
||||
const admin = await cntr.findUser("admin");
|
||||
if (admin === undefined) {
|
||||
throw new Error("initial process failed!"); // ???
|
||||
}
|
||||
return admin;
|
||||
};
|
||||
|
||||
export const isAdminFirst = (admin: IUser) => {
|
||||
return admin.password.hash === "unchecked" && admin.password.salt === "unchecked";
|
||||
};
|
129
packages/server/src/model/doc.ts
Normal file
129
packages/server/src/model/doc.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { JSONMap } from "../types/json";
|
||||
import { check_type } from "../util/type_check";
|
||||
import { TagAccessor } from "./tag";
|
||||
|
||||
export interface DocumentBody {
|
||||
title: string;
|
||||
content_type: string;
|
||||
basepath: string;
|
||||
filename: string;
|
||||
modified_at: number;
|
||||
content_hash: string;
|
||||
additional: JSONMap;
|
||||
tags: string[]; // eager loading
|
||||
}
|
||||
|
||||
export const MetaContentBody = {
|
||||
title: "string",
|
||||
content_type: "string",
|
||||
basepath: "string",
|
||||
filename: "string",
|
||||
content_hash: "string",
|
||||
additional: "object",
|
||||
tags: "string[]",
|
||||
};
|
||||
|
||||
export const isDocBody = (c: any): c is DocumentBody => {
|
||||
return check_type<DocumentBody>(c, MetaContentBody);
|
||||
};
|
||||
|
||||
export interface Document extends DocumentBody {
|
||||
readonly id: number;
|
||||
readonly created_at: number;
|
||||
readonly deleted_at: number | null;
|
||||
}
|
||||
|
||||
export const isDoc = (c: any): c is Document => {
|
||||
if ("id" in c && typeof c["id"] === "number") {
|
||||
const { id, ...rest } = c;
|
||||
return isDocBody(rest);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export type QueryListOption = {
|
||||
/**
|
||||
* search word
|
||||
*/
|
||||
word?: string;
|
||||
allow_tag?: string[];
|
||||
/**
|
||||
* limit of list
|
||||
* @default 20
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* use offset if true, otherwise
|
||||
* @default false
|
||||
*/
|
||||
use_offset?: boolean;
|
||||
/**
|
||||
* cursor of documents
|
||||
*/
|
||||
cursor?: number;
|
||||
/**
|
||||
* offset of documents
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* tag eager loading
|
||||
* @default true
|
||||
*/
|
||||
eager_loading?: boolean;
|
||||
/**
|
||||
* content type
|
||||
*/
|
||||
content_type?: string;
|
||||
};
|
||||
|
||||
export interface DocumentAccessor {
|
||||
/**
|
||||
* find list by option
|
||||
* @returns documents list
|
||||
*/
|
||||
findList: (option?: QueryListOption) => Promise<Document[]>;
|
||||
/**
|
||||
* @returns document if exist, otherwise undefined
|
||||
*/
|
||||
findById: (id: number, tagload?: boolean) => Promise<Document | undefined>;
|
||||
/**
|
||||
* find by base path and filename.
|
||||
* if you call this function with filename, its return array length is 0 or 1.
|
||||
*/
|
||||
findByPath: (basepath: string, filename?: string) => Promise<Document[]>;
|
||||
/**
|
||||
* find deleted content
|
||||
*/
|
||||
findDeleted: (content_type: string) => Promise<Document[]>;
|
||||
/**
|
||||
* search by in document
|
||||
*/
|
||||
search: (search_word: string) => Promise<Document[]>;
|
||||
/**
|
||||
* update document except tag.
|
||||
*/
|
||||
update: (c: Partial<Document> & { id: number }) => Promise<boolean>;
|
||||
/**
|
||||
* add document
|
||||
*/
|
||||
add: (c: DocumentBody) => Promise<number>;
|
||||
/**
|
||||
* add document list
|
||||
*/
|
||||
addList: (content_list: DocumentBody[]) => Promise<number[]>;
|
||||
/**
|
||||
* delete document
|
||||
* @returns if it exists, return true.
|
||||
*/
|
||||
del: (id: number) => Promise<boolean>;
|
||||
/**
|
||||
* @param c Valid Document
|
||||
* @param tagname tag name to add
|
||||
* @returns if success, return true
|
||||
*/
|
||||
addTag: (c: Document, tag_name: string) => Promise<boolean>;
|
||||
/**
|
||||
* @returns if success, return true
|
||||
*/
|
||||
delTag: (c: Document, tag_name: string) => Promise<boolean>;
|
||||
}
|
18
packages/server/src/model/tag.ts
Normal file
18
packages/server/src/model/tag.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export interface Tag {
|
||||
readonly name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TagCount {
|
||||
tag_name: string;
|
||||
occurs: number;
|
||||
}
|
||||
|
||||
export interface TagAccessor {
|
||||
getAllTagList: (onlyname?: boolean) => Promise<Tag[]>;
|
||||
getAllTagCount(): Promise<TagCount[]>;
|
||||
getTagByName: (name: string) => Promise<Tag | undefined>;
|
||||
addTag: (tag: Tag) => Promise<boolean>;
|
||||
delTag: (name: string) => Promise<boolean>;
|
||||
updateTag: (name: string, tag: string) => Promise<boolean>;
|
||||
}
|
84
packages/server/src/model/user.ts
Normal file
84
packages/server/src/model/user.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { createHmac, randomBytes } from "crypto";
|
||||
|
||||
function hashForPassword(salt: string, password: string) {
|
||||
return createHmac("sha256", salt).update(password).digest("hex");
|
||||
}
|
||||
function createPasswordHashAndSalt(password: string): { salt: string; hash: string } {
|
||||
const secret = randomBytes(32).toString("hex");
|
||||
return {
|
||||
salt: secret,
|
||||
hash: hashForPassword(secret, password),
|
||||
};
|
||||
}
|
||||
|
||||
export class Password {
|
||||
private _salt: string;
|
||||
private _hash: string;
|
||||
constructor(pw: string | { salt: string; hash: string }) {
|
||||
const { salt, hash } = typeof pw === "string" ? createPasswordHashAndSalt(pw) : pw;
|
||||
this._hash = hash;
|
||||
this._salt = salt;
|
||||
}
|
||||
set_password(password: string) {
|
||||
const { salt, hash } = createPasswordHashAndSalt(password);
|
||||
this._hash = hash;
|
||||
this._salt = salt;
|
||||
}
|
||||
check_password(password: string): boolean {
|
||||
return this._hash === hashForPassword(this._salt, password);
|
||||
}
|
||||
get salt() {
|
||||
return this._salt;
|
||||
}
|
||||
get hash() {
|
||||
return this._hash;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UserCreateInput {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface IUser {
|
||||
readonly username: string;
|
||||
readonly password: Password;
|
||||
/**
|
||||
* return user's permission list.
|
||||
*/
|
||||
get_permissions(): Promise<string[]>;
|
||||
/**
|
||||
* add permission
|
||||
* @param name permission name to add
|
||||
* @returns if `name` doesn't exist, return true
|
||||
*/
|
||||
add(name: string): Promise<boolean>;
|
||||
/**
|
||||
* remove permission
|
||||
* @param name permission name to remove
|
||||
* @returns if `name` exist, return true
|
||||
*/
|
||||
remove(name: string): Promise<boolean>;
|
||||
/**
|
||||
* reset password.
|
||||
* @param password password to set
|
||||
*/
|
||||
reset_password(password: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface UserAccessor {
|
||||
/**
|
||||
* create user
|
||||
* @returns if user exist, return undefined
|
||||
*/
|
||||
createUser: (input: UserCreateInput) => Promise<IUser | undefined>;
|
||||
/**
|
||||
* find user
|
||||
*/
|
||||
findUser: (username: string) => Promise<IUser | undefined>;
|
||||
/**
|
||||
* remove user
|
||||
* @returns if user exist, true
|
||||
*/
|
||||
delUser: (username: string) => Promise<boolean>;
|
||||
}
|
59
packages/server/src/permission/permission.ts
Normal file
59
packages/server/src/permission/permission.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import Koa from "koa";
|
||||
import { UserState } from "../login";
|
||||
import { sendError } from "../route/error_handler";
|
||||
|
||||
export enum Permission {
|
||||
// ========
|
||||
// not implemented
|
||||
// admin only
|
||||
/** remove document */
|
||||
// removeContent = 'removeContent',
|
||||
|
||||
/** upload document */
|
||||
// uploadContent = 'uploadContent',
|
||||
|
||||
/** modify document except base path, filename, content_hash. but admin can modify all. */
|
||||
// modifyContent = 'modifyContent',
|
||||
|
||||
/** add tag into document */
|
||||
// addTagContent = 'addTagContent',
|
||||
/** remove tag from document */
|
||||
// removeTagContent = 'removeTagContent',
|
||||
/** ModifyTagInDoc */
|
||||
ModifyTag = "ModifyTag",
|
||||
|
||||
/** find documents with query */
|
||||
// findAllContent = 'findAllContent',
|
||||
/** find one document. */
|
||||
// findOneContent = 'findOneContent',
|
||||
/** view content*/
|
||||
// viewContent = 'viewContent',
|
||||
QueryContent = "QueryContent",
|
||||
|
||||
/** modify description about the one tag. */
|
||||
modifyTagDesc = "ModifyTagDesc",
|
||||
}
|
||||
|
||||
export const createPermissionCheckMiddleware =
|
||||
(...permissions: string[]) =>
|
||||
async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
|
||||
const user = ctx.state["user"];
|
||||
if (user.username === "admin") {
|
||||
return await next();
|
||||
}
|
||||
const user_permission = user.permission;
|
||||
// if permissions is not subset of user permission
|
||||
if (!permissions.map((p) => user_permission.includes(p)).every((x) => x)) {
|
||||
if (user.username === "") {
|
||||
return sendError(401, "you are guest. login needed.");
|
||||
} else return sendError(403, "do not have permission");
|
||||
}
|
||||
await next();
|
||||
};
|
||||
export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
|
||||
const user = ctx.state["user"];
|
||||
if (user.username !== "admin") {
|
||||
return sendError(403, "admin only");
|
||||
}
|
||||
await next();
|
||||
};
|
57
packages/server/src/route/all.ts
Normal file
57
packages/server/src/route/all.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { DefaultContext, Middleware, Next, ParameterizedContext } from "koa";
|
||||
import compose from "koa-compose";
|
||||
import Router, { IParamMiddleware } from "koa-router";
|
||||
import ComicRouter from "./comic";
|
||||
import { ContentContext } from "./context";
|
||||
import VideoRouter from "./video";
|
||||
|
||||
const table: { [s: string]: Router | undefined } = {
|
||||
comic: new ComicRouter(),
|
||||
video: new VideoRouter(),
|
||||
};
|
||||
const all_middleware =
|
||||
(cont: string | undefined, restarg: string | undefined) =>
|
||||
async (ctx: ParameterizedContext<ContentContext, DefaultContext>, next: Next) => {
|
||||
if (cont == undefined) {
|
||||
ctx.status = 404;
|
||||
return;
|
||||
}
|
||||
if (ctx.state.location.type != cont) {
|
||||
console.error("not matched");
|
||||
ctx.status = 404;
|
||||
return;
|
||||
}
|
||||
const router = table[cont];
|
||||
if (router == undefined) {
|
||||
ctx.status = 404;
|
||||
return;
|
||||
}
|
||||
const rest = "/" + (restarg ?? "");
|
||||
const result = router.match(rest, "GET");
|
||||
if (!result.route) {
|
||||
return await next();
|
||||
}
|
||||
const chain = result.pathAndMethod.reduce((combination: Middleware<any & DefaultContext, any>[], cur) => {
|
||||
combination.push(async (ctx, next) => {
|
||||
const captures = cur.captures(rest);
|
||||
ctx.params = cur.params(rest, captures);
|
||||
ctx.request.params = ctx.params;
|
||||
ctx.routerPath = cur.path;
|
||||
return await next();
|
||||
});
|
||||
return combination.concat(cur.stack);
|
||||
}, []);
|
||||
return await compose(chain)(ctx, next);
|
||||
};
|
||||
export class AllContentRouter extends Router<ContentContext> {
|
||||
constructor() {
|
||||
super();
|
||||
this.get("/:content_type", async (ctx, next) => {
|
||||
return await all_middleware(ctx.params["content_type"], undefined)(ctx, next);
|
||||
});
|
||||
this.get("/:content_type/:rest(.*)", async (ctx, next) => {
|
||||
const cont = ctx.params["content_type"] as string;
|
||||
return await all_middleware(cont, ctx.params["rest"])(ctx, next);
|
||||
});
|
||||
}
|
||||
}
|
96
packages/server/src/route/comic.ts
Normal file
96
packages/server/src/route/comic.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { Context, DefaultContext, DefaultState, Next } from "koa";
|
||||
import Router from "koa-router";
|
||||
import { createReadableStreamFromZip, entriesByNaturalOrder, readZip, ZipAsync } from "../util/zipwrap";
|
||||
import { ContentContext } from "./context";
|
||||
import { since_last_modified } from "./util";
|
||||
|
||||
/**
|
||||
* zip stream cache.
|
||||
*/
|
||||
|
||||
let ZipStreamCache: { [path: string]: [ZipAsync, number] } = {};
|
||||
|
||||
async function acquireZip(path: string) {
|
||||
if (!(path in ZipStreamCache)) {
|
||||
const ret = await readZip(path);
|
||||
ZipStreamCache[path] = [ret, 1];
|
||||
// console.log(`acquire ${path} 1`);
|
||||
return ret;
|
||||
} else {
|
||||
const [ret, refCount] = ZipStreamCache[path];
|
||||
ZipStreamCache[path] = [ret, refCount + 1];
|
||||
// console.log(`acquire ${path} ${refCount + 1}`);
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseZip(path: string) {
|
||||
const obj = ZipStreamCache[path];
|
||||
if (obj === undefined) throw new Error("error! key invalid");
|
||||
const [ref, refCount] = obj;
|
||||
// console.log(`release ${path} : ${refCount}`);
|
||||
if (refCount === 1) {
|
||||
ref.close();
|
||||
delete ZipStreamCache[path];
|
||||
} else {
|
||||
ZipStreamCache[path] = [ref, refCount - 1];
|
||||
}
|
||||
}
|
||||
|
||||
async function renderZipImage(ctx: Context, path: string, page: number) {
|
||||
const image_ext = ["gif", "png", "jpeg", "bmp", "webp", "jpg"];
|
||||
// console.log(`opened ${page}`);
|
||||
let zip = await acquireZip(path);
|
||||
const entries = (await entriesByNaturalOrder(zip)).filter((x) => {
|
||||
const ext = x.name.split(".").pop();
|
||||
return ext !== undefined && image_ext.includes(ext);
|
||||
});
|
||||
if (0 <= page && page < entries.length) {
|
||||
const entry = entries[page];
|
||||
const last_modified = new Date(entry.time);
|
||||
if (since_last_modified(ctx, last_modified)) {
|
||||
return;
|
||||
}
|
||||
const read_stream = await createReadableStreamFromZip(zip, entry);
|
||||
/** Exceptions (ECONNRESET, ECONNABORTED) may be thrown when processing this request
|
||||
* for reasons such as when the browser unexpectedly closes the connection.
|
||||
* Once such an exception is raised, the stream is not properly destroyed,
|
||||
* so there is a problem with the zlib stream being accessed even after the stream is closed.
|
||||
* So it waits for 100 ms and releases it.
|
||||
* Additionaly, there is a risk of memory leak becuase zlib stream is not properly destroyed.
|
||||
* @todo modify function 'stream' in 'node-stream-zip' library to prevent memory leak */
|
||||
read_stream.once("close", () => {
|
||||
setTimeout(() => {
|
||||
releaseZip(path);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
ctx.body = read_stream;
|
||||
ctx.response.length = entry.size;
|
||||
// console.log(`${entry.name}'s ${page}:${entry.size}`);
|
||||
ctx.response.type = entry.name.split(".").pop() as string;
|
||||
ctx.status = 200;
|
||||
ctx.set("Date", new Date().toUTCString());
|
||||
ctx.set("Last-Modified", last_modified.toUTCString());
|
||||
} else {
|
||||
ctx.status = 404;
|
||||
}
|
||||
}
|
||||
|
||||
export class ComicRouter extends Router<ContentContext> {
|
||||
constructor() {
|
||||
super();
|
||||
this.get("/", async (ctx, next) => {
|
||||
await renderZipImage(ctx, ctx.state.location.path, 0);
|
||||
});
|
||||
this.get("/:page(\\d+)", async (ctx, next) => {
|
||||
const page = Number.parseInt(ctx.params["page"]);
|
||||
await renderZipImage(ctx, ctx.state.location.path, page);
|
||||
});
|
||||
this.get("/thumbnail", async (ctx, next) => {
|
||||
await renderZipImage(ctx, ctx.state.location.path, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default ComicRouter;
|
167
packages/server/src/route/contents.ts
Normal file
167
packages/server/src/route/contents.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { Context, Next } from "koa";
|
||||
import Router from "koa-router";
|
||||
import { join } from "path";
|
||||
import { Document, DocumentAccessor, isDocBody } from "../model/doc";
|
||||
import { QueryListOption } from "../model/doc";
|
||||
import {
|
||||
AdminOnlyMiddleware as AdminOnly,
|
||||
createPermissionCheckMiddleware as PerCheck,
|
||||
Permission as Per,
|
||||
} from "../permission/permission";
|
||||
import { AllContentRouter } from "./all";
|
||||
import { ContentLocation } from "./context";
|
||||
import { sendError } from "./error_handler";
|
||||
import { ParseQueryArgString, ParseQueryArray, ParseQueryBoolean, ParseQueryNumber } from "./util";
|
||||
|
||||
const ContentIDHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
|
||||
const num = Number.parseInt(ctx.params["num"]);
|
||||
let document = await controller.findById(num, true);
|
||||
if (document == undefined) {
|
||||
return sendError(404, "document does not exist.");
|
||||
}
|
||||
ctx.body = document;
|
||||
ctx.type = "json";
|
||||
console.log(document.additional);
|
||||
};
|
||||
const ContentTagIDHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
|
||||
const num = Number.parseInt(ctx.params["num"]);
|
||||
let document = await controller.findById(num, true);
|
||||
if (document == undefined) {
|
||||
return sendError(404, "document does not exist.");
|
||||
}
|
||||
ctx.body = document.tags;
|
||||
ctx.type = "json";
|
||||
};
|
||||
const ContentQueryHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
|
||||
let query_limit = ctx.query["limit"];
|
||||
let query_cursor = ctx.query["cursor"];
|
||||
let query_word = ctx.query["word"];
|
||||
let query_content_type = ctx.query["content_type"];
|
||||
let query_offset = ctx.query["offset"];
|
||||
let query_use_offset = ctx.query["use_offset"];
|
||||
if (
|
||||
query_limit instanceof Array ||
|
||||
query_cursor instanceof Array ||
|
||||
query_word instanceof Array ||
|
||||
query_content_type instanceof Array ||
|
||||
query_offset instanceof Array ||
|
||||
query_use_offset instanceof Array
|
||||
) {
|
||||
return sendError(400, "paramter can not be array");
|
||||
}
|
||||
const limit = ParseQueryNumber(query_limit);
|
||||
const cursor = ParseQueryNumber(query_cursor);
|
||||
const word = ParseQueryArgString(query_word);
|
||||
const content_type = ParseQueryArgString(query_content_type);
|
||||
const offset = ParseQueryNumber(query_offset);
|
||||
if (limit === NaN || cursor === NaN || offset === NaN) {
|
||||
return sendError(400, "parameter limit, cursor or offset is not a number");
|
||||
}
|
||||
const allow_tag = ParseQueryArray(ctx.query["allow_tag"]);
|
||||
const [ok, use_offset] = ParseQueryBoolean(query_use_offset);
|
||||
if (!ok) {
|
||||
return sendError(400, "use_offset must be true or false.");
|
||||
}
|
||||
const option: QueryListOption = {
|
||||
limit: limit,
|
||||
allow_tag: allow_tag,
|
||||
word: word,
|
||||
cursor: cursor,
|
||||
eager_loading: true,
|
||||
offset: offset,
|
||||
use_offset: use_offset,
|
||||
content_type: content_type,
|
||||
};
|
||||
let document = await controller.findList(option);
|
||||
ctx.body = document;
|
||||
ctx.type = "json";
|
||||
};
|
||||
const UpdateContentHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
|
||||
const num = Number.parseInt(ctx.params["num"]);
|
||||
|
||||
if (ctx.request.type !== "json") {
|
||||
return sendError(400, "update fail. invalid document type: it is not json.");
|
||||
}
|
||||
if (typeof ctx.request.body !== "object") {
|
||||
return sendError(400, "update fail. invalid argument: not");
|
||||
}
|
||||
const content_desc: Partial<Document> & { id: number } = {
|
||||
id: num,
|
||||
...ctx.request.body,
|
||||
};
|
||||
const success = await controller.update(content_desc);
|
||||
ctx.body = JSON.stringify(success);
|
||||
ctx.type = "json";
|
||||
};
|
||||
|
||||
const AddTagHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
|
||||
let tag_name = ctx.params["tag"];
|
||||
const num = Number.parseInt(ctx.params["num"]);
|
||||
if (typeof tag_name === undefined) {
|
||||
return sendError(400, "??? Unreachable");
|
||||
}
|
||||
tag_name = String(tag_name);
|
||||
const c = await controller.findById(num);
|
||||
if (c === undefined) {
|
||||
return sendError(404);
|
||||
}
|
||||
const r = await controller.addTag(c, tag_name);
|
||||
ctx.body = JSON.stringify(r);
|
||||
ctx.type = "json";
|
||||
};
|
||||
const DelTagHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
|
||||
let tag_name = ctx.params["tag"];
|
||||
const num = Number.parseInt(ctx.params["num"]);
|
||||
if (typeof tag_name === undefined) {
|
||||
return sendError(400, "?? Unreachable");
|
||||
}
|
||||
tag_name = String(tag_name);
|
||||
const c = await controller.findById(num);
|
||||
if (c === undefined) {
|
||||
return sendError(404);
|
||||
}
|
||||
const r = await controller.delTag(c, tag_name);
|
||||
ctx.body = JSON.stringify(r);
|
||||
ctx.type = "json";
|
||||
};
|
||||
const DeleteContentHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
|
||||
const num = Number.parseInt(ctx.params["num"]);
|
||||
const r = await controller.del(num);
|
||||
ctx.body = JSON.stringify(r);
|
||||
ctx.type = "json";
|
||||
};
|
||||
const ContentHandler = (controller: DocumentAccessor) => async (ctx: Context, next: Next) => {
|
||||
const num = Number.parseInt(ctx.params["num"]);
|
||||
let document = await controller.findById(num, true);
|
||||
if (document == undefined) {
|
||||
return sendError(404, "document does not exist.");
|
||||
}
|
||||
if (document.deleted_at !== null) {
|
||||
return sendError(404, "document has been removed.");
|
||||
}
|
||||
const path = join(document.basepath, document.filename);
|
||||
ctx.state["location"] = {
|
||||
path: path,
|
||||
type: document.content_type,
|
||||
additional: document.additional,
|
||||
} as ContentLocation;
|
||||
await next();
|
||||
};
|
||||
|
||||
export const getContentRouter = (controller: DocumentAccessor) => {
|
||||
const ret = new Router();
|
||||
ret.get("/search", PerCheck(Per.QueryContent), ContentQueryHandler(controller));
|
||||
ret.get("/:num(\\d+)", PerCheck(Per.QueryContent), ContentIDHandler(controller));
|
||||
ret.post("/:num(\\d+)", AdminOnly, UpdateContentHandler(controller));
|
||||
// ret.use("/:num(\\d+)/:content_type");
|
||||
// ret.post("/",AdminOnly,CreateContentHandler(controller));
|
||||
ret.get("/:num(\\d+)/tags", PerCheck(Per.QueryContent), ContentTagIDHandler(controller));
|
||||
ret.post("/:num(\\d+)/tags/:tag", PerCheck(Per.ModifyTag), AddTagHandler(controller));
|
||||
ret.del("/:num(\\d+)/tags/:tag", PerCheck(Per.ModifyTag), DelTagHandler(controller));
|
||||
ret.del("/:num(\\d+)", AdminOnly, DeleteContentHandler(controller));
|
||||
ret.all("/:num(\\d+)/(.*)", PerCheck(Per.QueryContent), ContentHandler(controller));
|
||||
ret.use("/:num(\\d+)", PerCheck(Per.QueryContent), new AllContentRouter().routes());
|
||||
return ret;
|
||||
};
|
||||
|
||||
export default getContentRouter;
|
8
packages/server/src/route/context.ts
Normal file
8
packages/server/src/route/context.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export type ContentLocation = {
|
||||
path: string;
|
||||
type: string;
|
||||
additional: object | undefined;
|
||||
};
|
||||
export interface ContentContext {
|
||||
location: ContentLocation;
|
||||
}
|
49
packages/server/src/route/error_handler.ts
Normal file
49
packages/server/src/route/error_handler.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { Context, Next } from "koa";
|
||||
|
||||
export interface ErrorFormat {
|
||||
code: number;
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
class ClientRequestError implements Error {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string | undefined;
|
||||
code: number;
|
||||
|
||||
constructor(code: number, message: string) {
|
||||
this.name = "client request error";
|
||||
this.message = message;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const code_to_message_table: { [key: number]: string | undefined } = {
|
||||
400: "BadRequest",
|
||||
404: "NotFound",
|
||||
};
|
||||
|
||||
export const error_handler = async (ctx: Context, next: Next) => {
|
||||
try {
|
||||
await next();
|
||||
} catch (err) {
|
||||
if (err instanceof ClientRequestError) {
|
||||
const body: ErrorFormat = {
|
||||
code: err.code,
|
||||
message: code_to_message_table[err.code] ?? "",
|
||||
detail: err.message,
|
||||
};
|
||||
ctx.status = err.code;
|
||||
ctx.body = body;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const sendError = (code: number, message?: string) => {
|
||||
throw new ClientRequestError(code, message ?? "");
|
||||
};
|
||||
|
||||
export default error_handler;
|
29
packages/server/src/route/tags.ts
Normal file
29
packages/server/src/route/tags.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Context, Next } from "koa";
|
||||
import Router, { RouterContext } from "koa-router";
|
||||
import { TagAccessor } from "../model/tag";
|
||||
import { createPermissionCheckMiddleware as PerCheck, Permission } from "../permission/permission";
|
||||
import { sendError } from "./error_handler";
|
||||
|
||||
export function getTagRounter(tagController: TagAccessor) {
|
||||
let router = new Router();
|
||||
router.get("/", PerCheck(Permission.QueryContent), async (ctx: Context) => {
|
||||
if (ctx.query["withCount"]) {
|
||||
const c = await tagController.getAllTagCount();
|
||||
ctx.body = c;
|
||||
} else {
|
||||
const c = await tagController.getAllTagList();
|
||||
ctx.body = c;
|
||||
}
|
||||
ctx.type = "json";
|
||||
});
|
||||
router.get("/:tag_name", PerCheck(Permission.QueryContent), async (ctx: RouterContext) => {
|
||||
const tag_name = ctx.params["tag_name"];
|
||||
const c = await tagController.getTagByName(tag_name);
|
||||
if (!c) {
|
||||
sendError(404, "tags not found");
|
||||
}
|
||||
ctx.body = c;
|
||||
ctx.type = "json";
|
||||
});
|
||||
return router;
|
||||
}
|
37
packages/server/src/route/util.ts
Normal file
37
packages/server/src/route/util.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Context } from "koa";
|
||||
|
||||
export function ParseQueryNumber(s: string[] | string | undefined): number | undefined {
|
||||
if (s === undefined) return undefined;
|
||||
else if (typeof s === "object") return undefined;
|
||||
else return Number.parseInt(s);
|
||||
}
|
||||
export function ParseQueryArray(s: string[] | string | undefined) {
|
||||
s = s ?? [];
|
||||
const r = s instanceof Array ? s : [s];
|
||||
return r.map((x) => decodeURIComponent(x));
|
||||
}
|
||||
export function ParseQueryArgString(s: string[] | string | undefined) {
|
||||
if (typeof s === "object") return undefined;
|
||||
return s === undefined ? s : decodeURIComponent(s);
|
||||
}
|
||||
export function ParseQueryBoolean(s: string[] | string | undefined): [boolean, boolean | undefined] {
|
||||
let value: boolean | undefined;
|
||||
|
||||
if (s === "true") {
|
||||
value = true;
|
||||
} else if (s === "false") {
|
||||
value = false;
|
||||
} else if (s === undefined) {
|
||||
value = undefined;
|
||||
} else return [false, undefined];
|
||||
return [true, value];
|
||||
}
|
||||
|
||||
export function since_last_modified(ctx: Context, last_modified: Date): boolean {
|
||||
const con = ctx.get("If-Modified-Since");
|
||||
if (con === "") return false;
|
||||
const mdate = new Date(con);
|
||||
if (last_modified > mdate) return false;
|
||||
ctx.status = 304;
|
||||
return true;
|
||||
}
|
67
packages/server/src/route/video.ts
Normal file
67
packages/server/src/route/video.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { createReadStream, promises } from "fs";
|
||||
import { Context } from "koa";
|
||||
import Router from "koa-router";
|
||||
import { ContentContext } from "./context";
|
||||
|
||||
export async function renderVideo(ctx: Context, path: string) {
|
||||
const ext = path.trim().split(".").pop();
|
||||
if (ext === undefined) {
|
||||
// ctx.status = 404;
|
||||
console.error(`${path}:${ext}`);
|
||||
return;
|
||||
}
|
||||
ctx.response.type = ext;
|
||||
const range_text = ctx.request.get("range");
|
||||
const stat = await promises.stat(path);
|
||||
let start = 0;
|
||||
let end = 0;
|
||||
ctx.set("Last-Modified", new Date(stat.mtime).toUTCString());
|
||||
ctx.set("Date", new Date().toUTCString());
|
||||
ctx.set("Accept-Ranges", "bytes");
|
||||
if (range_text === "") {
|
||||
end = 1024 * 512;
|
||||
end = Math.min(end, stat.size - 1);
|
||||
if (start > end) {
|
||||
ctx.status = 416;
|
||||
return;
|
||||
}
|
||||
ctx.status = 200;
|
||||
ctx.length = stat.size;
|
||||
let stream = createReadStream(path);
|
||||
ctx.body = stream;
|
||||
} else {
|
||||
const m = range_text.match(/^bytes=(\d+)-(\d*)/);
|
||||
if (m === null) {
|
||||
ctx.status = 416;
|
||||
return;
|
||||
}
|
||||
start = parseInt(m[1]);
|
||||
end = m[2].length > 0 ? parseInt(m[2]) : start + 1024 * 1024;
|
||||
end = Math.min(end, stat.size - 1);
|
||||
if (start > end) {
|
||||
ctx.status = 416;
|
||||
return;
|
||||
}
|
||||
ctx.status = 206;
|
||||
ctx.length = end - start + 1;
|
||||
ctx.response.set("Content-Range", `bytes ${start}-${end}/${stat.size}`);
|
||||
ctx.body = createReadStream(path, {
|
||||
start: start,
|
||||
end: end,
|
||||
}); // inclusive range.
|
||||
}
|
||||
}
|
||||
|
||||
export class VideoRouter extends Router<ContentContext> {
|
||||
constructor() {
|
||||
super();
|
||||
this.get("/", async (ctx, next) => {
|
||||
await renderVideo(ctx, ctx.state.location.path);
|
||||
});
|
||||
this.get("/thumbnail", async (ctx, next) => {
|
||||
await renderVideo(ctx, ctx.state.location.path);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default VideoRouter;
|
12
packages/server/src/search/indexer.ts
Normal file
12
packages/server/src/search/indexer.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export interface PaginationOption {
|
||||
cursor: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface IIndexer {
|
||||
indexDoc(word: string, doc_id: number): boolean;
|
||||
indexDoc(word: string[], doc_id: number): boolean;
|
||||
|
||||
getDoc(word: string, option?: PaginationOption): number[];
|
||||
getDoc(word: string[], option?: PaginationOption): number[];
|
||||
}
|
9
packages/server/src/search/tokenizer.ts
Normal file
9
packages/server/src/search/tokenizer.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export interface ITokenizer {
|
||||
tokenize(s: string): string[];
|
||||
}
|
||||
|
||||
export class DefaultTokenizer implements ITokenizer {
|
||||
tokenize(s: string): string[] {
|
||||
return s.split(" ");
|
||||
}
|
||||
}
|
237
packages/server/src/server.ts
Normal file
237
packages/server/src/server.ts
Normal file
@ -0,0 +1,237 @@
|
||||
import Koa from "koa";
|
||||
import Router from "koa-router";
|
||||
|
||||
import { connectDB } from "./database";
|
||||
import { createDiffRouter, DiffManager } from "./diff/mod";
|
||||
import { get_setting, SettingConfig } from "./SettingConfig";
|
||||
|
||||
import { createReadStream, readFileSync } from "fs";
|
||||
import bodyparser from "koa-bodyparser";
|
||||
import { createKnexDocumentAccessor, createKnexTagController, createKnexUserController } from "./db/mod";
|
||||
import { createLoginRouter, createUserMiddleWare, getAdmin, isAdminFirst } from "./login";
|
||||
import getContentRouter from "./route/contents";
|
||||
import { error_handler } from "./route/error_handler";
|
||||
|
||||
import { createInterface as createReadlineInterface } from "readline";
|
||||
import { createComicWatcher } from "./diff/watcher/comic_watcher";
|
||||
import { DocumentAccessor, TagAccessor, UserAccessor } from "./model/mod";
|
||||
import { getTagRounter } from "./route/tags";
|
||||
|
||||
class ServerApplication {
|
||||
readonly userController: UserAccessor;
|
||||
readonly documentController: DocumentAccessor;
|
||||
readonly tagController: TagAccessor;
|
||||
readonly diffManger: DiffManager;
|
||||
readonly app: Koa;
|
||||
private index_html: string;
|
||||
private constructor(controller: {
|
||||
userController: UserAccessor;
|
||||
documentController: DocumentAccessor;
|
||||
tagController: TagAccessor;
|
||||
}) {
|
||||
this.userController = controller.userController;
|
||||
this.documentController = controller.documentController;
|
||||
this.tagController = controller.tagController;
|
||||
|
||||
this.diffManger = new DiffManager(this.documentController);
|
||||
this.app = new Koa();
|
||||
this.index_html = readFileSync("index.html", "utf-8");
|
||||
}
|
||||
private async setup() {
|
||||
const setting = get_setting();
|
||||
const app = this.app;
|
||||
|
||||
if (setting.cli) {
|
||||
const userAdmin = await getAdmin(this.userController);
|
||||
if (await isAdminFirst(userAdmin)) {
|
||||
const rl = createReadlineInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
const pw = await new Promise((res: (data: string) => void, err) => {
|
||||
rl.question("put admin password :", (data) => {
|
||||
res(data);
|
||||
});
|
||||
});
|
||||
rl.close();
|
||||
userAdmin.reset_password(pw);
|
||||
}
|
||||
}
|
||||
app.use(bodyparser());
|
||||
app.use(error_handler);
|
||||
app.use(createUserMiddleWare(this.userController));
|
||||
|
||||
let diff_router = createDiffRouter(this.diffManger);
|
||||
this.diffManger.register("comic", createComicWatcher());
|
||||
|
||||
console.log("setup router");
|
||||
|
||||
let router = new Router();
|
||||
router.use("/api/(.*)", async (ctx, next) => {
|
||||
// For CORS
|
||||
ctx.res.setHeader("access-control-allow-origin", "*");
|
||||
await next();
|
||||
});
|
||||
|
||||
router.use("/api/diff", diff_router.routes());
|
||||
router.use("/api/diff", diff_router.allowedMethods());
|
||||
|
||||
const content_router = getContentRouter(this.documentController);
|
||||
router.use("/api/doc", content_router.routes());
|
||||
router.use("/api/doc", content_router.allowedMethods());
|
||||
|
||||
const tags_router = getTagRounter(this.tagController);
|
||||
router.use("/api/tags", tags_router.allowedMethods());
|
||||
router.use("/api/tags", tags_router.routes());
|
||||
|
||||
this.serve_with_meta_index(router);
|
||||
this.serve_index(router);
|
||||
this.serve_static_file(router);
|
||||
|
||||
const login_router = createLoginRouter(this.userController);
|
||||
router.use("/user", login_router.routes());
|
||||
router.use("/user", login_router.allowedMethods());
|
||||
|
||||
if (setting.mode == "development") {
|
||||
let mm_count = 0;
|
||||
app.use(async (ctx, next) => {
|
||||
console.log(`==========================${mm_count++}`);
|
||||
const ip = ctx.get("X-Real-IP") ?? ctx.ip;
|
||||
const fromClient = ctx.state["user"].username === "" ? ip : ctx.state["user"].username;
|
||||
console.log(`${fromClient} : ${ctx.method} ${ctx.url}`);
|
||||
await next();
|
||||
// console.log(`404`);
|
||||
});
|
||||
}
|
||||
app.use(router.routes());
|
||||
app.use(router.allowedMethods());
|
||||
console.log("setup done");
|
||||
}
|
||||
private serve_index(router: Router) {
|
||||
const serveindex = (url: string) => {
|
||||
router.get(url, (ctx) => {
|
||||
ctx.type = "html";
|
||||
ctx.body = this.index_html;
|
||||
const setting = get_setting();
|
||||
ctx.set("x-content-type-options", "no-sniff");
|
||||
if (setting.mode === "development") {
|
||||
ctx.set("cache-control", "no-cache");
|
||||
} else {
|
||||
ctx.set("cache-control", "public, max-age=3600");
|
||||
}
|
||||
});
|
||||
};
|
||||
serveindex("/");
|
||||
serveindex("/doc/:rest(.*)");
|
||||
serveindex("/search");
|
||||
serveindex("/login");
|
||||
serveindex("/profile");
|
||||
serveindex("/difference");
|
||||
serveindex("/setting");
|
||||
serveindex("/tags");
|
||||
}
|
||||
private serve_with_meta_index(router: Router) {
|
||||
const DocMiddleware = async (ctx: Koa.ParameterizedContext) => {
|
||||
const docId = Number.parseInt(ctx.params["id"]);
|
||||
const doc = await this.documentController.findById(docId, true);
|
||||
let meta;
|
||||
if (doc === undefined) {
|
||||
ctx.status = 404;
|
||||
meta = NotFoundContent();
|
||||
} else {
|
||||
ctx.status = 200;
|
||||
meta = createOgTagContent(
|
||||
doc.title,
|
||||
doc.tags.join(", "),
|
||||
`https://aeolian.prelude.duckdns.org/api/doc/${docId}/comic/thumbnail`,
|
||||
);
|
||||
}
|
||||
const html = makeMetaTagInjectedHTML(this.index_html, meta);
|
||||
serveHTML(ctx, html);
|
||||
};
|
||||
router.get("/doc/:id(\\d+)", DocMiddleware);
|
||||
|
||||
function NotFoundContent() {
|
||||
return createOgTagContent("Not Found Doc", "Not Found", "");
|
||||
}
|
||||
function makeMetaTagInjectedHTML(html: string, tagContent: string) {
|
||||
return html.replace("<!--MetaTag-Outlet-->", tagContent);
|
||||
}
|
||||
function serveHTML(ctx: Koa.Context, file: string) {
|
||||
ctx.type = "html";
|
||||
ctx.body = file;
|
||||
const setting = get_setting();
|
||||
ctx.set("x-content-type-options", "no-sniff");
|
||||
if (setting.mode === "development") {
|
||||
ctx.set("cache-control", "no-cache");
|
||||
} else {
|
||||
ctx.set("cache-control", "public, max-age=3600");
|
||||
}
|
||||
}
|
||||
|
||||
function createMetaTagContent(key: string, value: string) {
|
||||
return `<meta property="${key}" content="${value}">`;
|
||||
}
|
||||
function createOgTagContent(title: string, description: string, image: string) {
|
||||
return [
|
||||
createMetaTagContent("og:title", title),
|
||||
createMetaTagContent("og:type", "website"),
|
||||
createMetaTagContent("og:description", description),
|
||||
createMetaTagContent("og:image", image),
|
||||
// createMetaTagContent("og:image:width","480"),
|
||||
// createMetaTagContent("og:image","480"),
|
||||
// createMetaTagContent("og:image:type","image/png"),
|
||||
createMetaTagContent("twitter:card", "summary_large_image"),
|
||||
createMetaTagContent("twitter:title", title),
|
||||
createMetaTagContent("twitter:description", description),
|
||||
createMetaTagContent("twitter:image", image),
|
||||
].join("\n");
|
||||
}
|
||||
}
|
||||
private serve_static_file(router: Router) {
|
||||
const static_file_server = (path: string, type: string) => {
|
||||
router.get("/" + path, async (ctx, next) => {
|
||||
const setting = get_setting();
|
||||
ctx.type = type;
|
||||
ctx.body = createReadStream(path);
|
||||
ctx.set("x-content-type-options", "no-sniff");
|
||||
if (setting.mode === "development") {
|
||||
ctx.set("cache-control", "no-cache");
|
||||
} else {
|
||||
ctx.set("cache-control", "public, max-age=3600");
|
||||
}
|
||||
});
|
||||
};
|
||||
const setting = get_setting();
|
||||
static_file_server("dist/bundle.css", "css");
|
||||
static_file_server("dist/bundle.js", "js");
|
||||
if (setting.mode === "development") {
|
||||
static_file_server("dist/bundle.js.map", "text");
|
||||
static_file_server("dist/bundle.css.map", "text");
|
||||
}
|
||||
}
|
||||
start_server() {
|
||||
let setting = get_setting();
|
||||
// todo : support https
|
||||
console.log(`listen on http://${setting.localmode ? "localhost" : "0.0.0.0"}:${setting.port}`);
|
||||
return this.app.listen(setting.port, setting.localmode ? "127.0.0.1" : "0.0.0.0");
|
||||
}
|
||||
static async createServer() {
|
||||
const setting = get_setting();
|
||||
let db = await connectDB();
|
||||
|
||||
const app = new ServerApplication({
|
||||
userController: createKnexUserController(db),
|
||||
documentController: createKnexDocumentAccessor(db),
|
||||
tagController: createKnexTagController(db),
|
||||
});
|
||||
await app.setup();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create_server() {
|
||||
return await ServerApplication.createServer();
|
||||
}
|
||||
|
||||
export default { create_server };
|
34
packages/server/src/types/db.d.ts
vendored
Normal file
34
packages/server/src/types/db.d.ts
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
declare module "knex" {
|
||||
interface Tables {
|
||||
tags: {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
users: {
|
||||
username: string;
|
||||
password_hash: string;
|
||||
password_salt: string;
|
||||
};
|
||||
document: {
|
||||
id: number;
|
||||
title: string;
|
||||
content_type: string;
|
||||
basepath: string;
|
||||
filename: string;
|
||||
created_at: number;
|
||||
deleted_at: number | null;
|
||||
content_hash: string;
|
||||
additional: string | null;
|
||||
};
|
||||
doc_tag_relation: {
|
||||
doc_id: number;
|
||||
tag_name: string;
|
||||
};
|
||||
permissions: {
|
||||
username: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
}
|
51
packages/server/src/util/configRW.ts
Normal file
51
packages/server/src/util/configRW.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { existsSync, promises as fs, readFileSync, writeFileSync } from "fs";
|
||||
import { validate } from "jsonschema";
|
||||
|
||||
export class ConfigManager<T> {
|
||||
path: string;
|
||||
default_config: T;
|
||||
config: T | null;
|
||||
schema: object;
|
||||
constructor(path: string, default_config: T, schema: object) {
|
||||
this.path = path;
|
||||
this.default_config = default_config;
|
||||
this.config = null;
|
||||
this.schema = schema;
|
||||
}
|
||||
get_config_file(): T {
|
||||
if (this.config !== null) return this.config;
|
||||
this.config = { ...this.read_config_file() };
|
||||
return this.config;
|
||||
}
|
||||
private emptyToDefault(target: T) {
|
||||
let occur = false;
|
||||
for (const key in this.default_config) {
|
||||
if (key === undefined || key in target) {
|
||||
continue;
|
||||
}
|
||||
target[key] = this.default_config[key];
|
||||
occur = true;
|
||||
}
|
||||
return occur;
|
||||
}
|
||||
read_config_file(): T {
|
||||
if (!existsSync(this.path)) {
|
||||
writeFileSync(this.path, JSON.stringify(this.default_config));
|
||||
return this.default_config;
|
||||
}
|
||||
const ret = JSON.parse(readFileSync(this.path, { encoding: "utf8" }));
|
||||
if (this.emptyToDefault(ret)) {
|
||||
writeFileSync(this.path, JSON.stringify(ret));
|
||||
}
|
||||
const result = validate(ret, this.schema);
|
||||
if (!result.valid) {
|
||||
throw new Error(result.toString());
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
async write_config_file(new_config: T) {
|
||||
this.config = new_config;
|
||||
await fs.writeFile(`${this.path}.temp`, JSON.stringify(new_config));
|
||||
await fs.rename(`${this.path}.temp`, this.path);
|
||||
}
|
||||
}
|
15
packages/server/src/util/type_check.ts
Normal file
15
packages/server/src/util/type_check.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export function check_type<T>(obj: any, check_proto: Record<string, string | undefined>): obj is T {
|
||||
for (const it in check_proto) {
|
||||
let defined = check_proto[it];
|
||||
if (defined === undefined) return false;
|
||||
defined = defined.trim();
|
||||
if (defined.endsWith("[]")) {
|
||||
if (!(obj[it] instanceof Array)) {
|
||||
return false;
|
||||
}
|
||||
} else if (defined !== typeof obj[it]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user