Rework #6
					 169 changed files with 7149 additions and 8887 deletions
				
			
		
							
								
								
									
										4
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -12,6 +12,6 @@ db.sqlite3
 | 
			
		|||
build/**
 | 
			
		||||
app/**
 | 
			
		||||
settings.json
 | 
			
		||||
*config.json
 | 
			
		||||
 | 
			
		||||
.pnpm-store/**
 | 
			
		||||
.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.");
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										90
									
								
								package.json
									
										
									
									
									
								
							
							
						
						
									
										90
									
								
								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"
 | 
			
		||||
  },
 | 
			
		||||
  "build": {
 | 
			
		||||
    "asar": true,
 | 
			
		||||
    "files": [
 | 
			
		||||
      "build/**/*",
 | 
			
		||||
      "node_modules/**/*",
 | 
			
		||||
      "package.json"
 | 
			
		||||
    ],
 | 
			
		||||
    "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": "."
 | 
			
		||||
    }
 | 
			
		||||
    "test": "echo \"Error: no test specified\" && exit 1",
 | 
			
		||||
    "format": "biome format --write",
 | 
			
		||||
    "lint": "biome lint"
 | 
			
		||||
  },
 | 
			
		||||
  "keywords": [],
 | 
			
		||||
  "workspaces": [
 | 
			
		||||
		"packages/*"
 | 
			
		||||
	],
 | 
			
		||||
  "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 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>,
 | 
			
		||||
)
 | 
			
		||||
| 
						 | 
				
			
			@ -4,12 +4,12 @@ import { CommonMenuList, Headline } from "../component/mod";
 | 
			
		|||
import { PagePad } from "../component/pagepad";
 | 
			
		||||
 | 
			
		||||
export const NotFoundPage = () => {
 | 
			
		||||
    const menu = CommonMenuList();
 | 
			
		||||
    return (
 | 
			
		||||
        <Headline menu={menu}>
 | 
			
		||||
            <PagePad>
 | 
			
		||||
                <Typography variant="h2">404 Not Found</Typography>
 | 
			
		||||
            </PagePad>
 | 
			
		||||
        </Headline>
 | 
			
		||||
    );
 | 
			
		||||
	const menu = CommonMenuList();
 | 
			
		||||
	return (
 | 
			
		||||
		<Headline menu={menu}>
 | 
			
		||||
			<PagePad>
 | 
			
		||||
				<Typography variant="h2">404 Not Found</Typography>
 | 
			
		||||
			</PagePad>
 | 
			
		||||
		</Headline>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -2,8 +2,8 @@ import { ContentConstructOption, ContentFile, registerContentReferrer } from "./
 | 
			
		|||
import { createDefaultClass } from "./file";
 | 
			
		||||
 | 
			
		||||
export class VideoReferrer extends createDefaultClass("video") {
 | 
			
		||||
    constructor(path: string, desc?: ContentConstructOption) {
 | 
			
		||||
        super(path, desc);
 | 
			
		||||
    }
 | 
			
		||||
	constructor(path: string, desc?: ContentConstructOption) {
 | 
			
		||||
		super(path, desc);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
registerContentReferrer(VideoReferrer);
 | 
			
		||||
							
								
								
									
										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
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
import { ConfigManager } from "../../util/configRW";
 | 
			
		||||
import ComicSchema from "./ComicConfig.schema.json";
 | 
			
		||||
export interface ComicConfig {
 | 
			
		||||
    watch: string[];
 | 
			
		||||
	watch: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ComicConfig = new ConfigManager<ComicConfig>("comic_config.json", { watch: [] }, ComicSchema);
 | 
			
		||||
| 
						 | 
				
			
			@ -7,10 +7,10 @@ import { RecursiveWatcher } from "./recursive_watcher";
 | 
			
		|||
import { WatcherFilter } from "./watcher_filter";
 | 
			
		||||
 | 
			
		||||
const createComicWatcherBase = (path: string) => {
 | 
			
		||||
    return new WatcherFilter(new RecursiveWatcher(path), (x) => x.endsWith(".zip"));
 | 
			
		||||
	return new WatcherFilter(new RecursiveWatcher(path), (x) => x.endsWith(".zip"));
 | 
			
		||||
};
 | 
			
		||||
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)));
 | 
			
		||||
	const file = ComicConfig.get_config_file();
 | 
			
		||||
	console.log(`register comic ${file.watch.join(",")}`);
 | 
			
		||||
	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…
	
	Add table
		
		Reference in a new issue