diff --git a/db.ts b/db.ts index 6eb94b5..4f5b1a2 100644 --- a/db.ts +++ b/db.ts @@ -141,7 +141,8 @@ export enum UserPermission { EditGallery = 1 << 1, DeleteGallery = 1 << 2, ManageTasks = 1 << 3, - All = ~(~0 << 4), + ShareGallery = 1 << 4, + All = ~(~0 << 5), } export type User = { id: number | bigint; @@ -189,6 +190,29 @@ export type ClientConfig = { name: string; data: string; }; +export enum SharedTokenType { + Gallery, +} +export type GallerySharedTokenInfo = { + gid: number | bigint; +}; +type SharedTokenTypeMap = { + [SharedTokenType.Gallery]: GallerySharedTokenInfo; +}; +export type SharedToken = { + id: number | bigint; + token: string; + expired: Date | null; + type: T; + info: SharedTokenTypeMap[T]; +}; +type SharedTokenRaw = { + id: number | bigint; + token: string; + expired: Date | null; + type: SharedTokenType; + info: string; +}; const ALL_TABLES = [ "version", "task", @@ -202,6 +226,7 @@ const ALL_TABLES = [ "token", "ehmeta", "client_config", + "shared_token", ]; const VERSION_TABLE = `CREATE TABLE version ( id TEXT, @@ -302,6 +327,13 @@ const CLIENT_CONFIG_TABLE = `CREATE TABLE client_config ( data TEXT, PRIMARY KEY (uid, client, name) );`; +const SHARED_TOKEN_TABLE = `CREATE TABLE shared_token ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT, + expired TEXT, + type INT, + info TEXT +);`; function escape_fields(fields: string, namespace: string) { const fs = fields.split(","); @@ -323,7 +355,7 @@ export class EhDb { #base_path: string; #db_path: string; #use_ffi = false; - readonly version = parse_ver("1.0.0-12"); + readonly version = parse_ver("1.0.0-13"); constructor(base_path: string) { this.#base_path = base_path; this.#db_path = join(base_path, "data.db"); @@ -531,6 +563,9 @@ export class EhDb { if (!this.#exist_table.has("client_config")) { this.db.execute(CLIENT_CONFIG_TABLE); } + if (!this.#exist_table.has("shared_token")) { + this.db.execute(SHARED_TOKEN_TABLE); + } this.#updateExistsTable(); } #read_version() { @@ -667,6 +702,23 @@ export class EhDb { UserPermission.All, ]); } + add_shared_token( + type: T, + info: SharedTokenTypeMap[T], + expired: Date | null = null, + ) { + let token = randomstring(); + while (this.get_token(token) || this.get_shared_token(token)) { + token = randomstring(); + } + this.db.query( + "INSERT INTO shared_token (token, expired, type, info) VALUES (?, ?, ?, ?);", + [token, expired, type, toJSON(info)], + ); + const t = this.get_shared_token(token); + if (!t) throw Error("Failed to add shared token"); + return t; + } add_task(task: Task) { return this.transaction(() => { this.db.query( @@ -713,7 +765,7 @@ export class EhDb { client_platform: string | null, ): Token { let token = randomstring(); - while (this.get_token(token)) { + while (this.get_token(token) || this.get_shared_token(token)) { token = randomstring(); } this.db.query( @@ -916,6 +968,15 @@ export class EhDb { return t; }); } + convert_shared_token(m: SharedTokenRaw[]) { + return m.map((m) => { + const e = m.expired ? new Date(m.expired) : null; + const t = m; + t.expired = e; + t.info = JSON.parse(m.info); + return t; + }); + } convert_token(m: TokenRaw[]) { return m.map((m) => { const e = new Date(m.expired); @@ -1305,6 +1366,15 @@ export class EhDb { ]) ); } + get_shared_token(token: string) { + const s = this.convert_shared_token( + this.db.queryEntries( + "SELECT * FROM shared_token WHERE token = ?;", + [token], + ), + ); + return s.length ? s[0] : undefined; + } get_token(token: string) { const s = this.convert_token( this.db.queryEntries( diff --git a/fresh.gen.ts b/fresh.gen.ts index 2b6ba3d..38bbfed 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -22,6 +22,7 @@ import * as $api_gallery_gid_ from "./routes/api/gallery/[gid].ts"; import * as $api_gallery_list from "./routes/api/gallery/list.ts"; import * as $api_gallery_meta_gids_ from "./routes/api/gallery/meta/[gids].ts"; import * as $api_health_check from "./routes/api/health_check.ts"; +import * as $api_shared_token from "./routes/api/shared_token.ts"; import * as $api_status from "./routes/api/status.ts"; import * as $api_tag_id_ from "./routes/api/tag/[id].ts"; import * as $api_tag_rows from "./routes/api/tag/rows.ts"; @@ -69,6 +70,7 @@ const manifest = { "./routes/api/gallery/list.ts": $api_gallery_list, "./routes/api/gallery/meta/[gids].ts": $api_gallery_meta_gids_, "./routes/api/health_check.ts": $api_health_check, + "./routes/api/shared_token.ts": $api_shared_token, "./routes/api/status.ts": $api_status, "./routes/api/tag/[id].ts": $api_tag_id_, "./routes/api/tag/rows.ts": $api_tag_rows, diff --git a/routes/api/_middleware.ts b/routes/api/_middleware.ts index ec9ef98..cff3cc2 100644 --- a/routes/api/_middleware.ts +++ b/routes/api/_middleware.ts @@ -2,7 +2,7 @@ import { FreshContext } from "$fresh/server.ts"; import { get_task_manager } from "../../server.ts"; import { parse_cookies } from "../../server/cookies.ts"; import { return_error } from "../../server/utils.ts"; -import type { Token } from "../../db.ts"; +import { SharedTokenType, type Token } from "../../db.ts"; function handle_auth(req: Request, ctx: FreshContext) { if (req.method === "OPTIONS") return true; @@ -44,7 +44,45 @@ function handle_auth(req: Request, ctx: FreshContext) { if (!token) return check(); const t = m.db.get_token(token); const now = (new Date()).getTime(); - if (!t || t.expired.getTime() < now) return check(); + if (!t) { + const st = m.db.get_shared_token(token); + if (!st || (st.expired !== null && st.expired.getTime() < now)) { + return check(); + } + if ( + u.pathname === "/api/shared_token" && req.method === "GET" + ) { /*Nothing to do*/ } else if (st.type == SharedTokenType.Gallery) { + const check_g = () => { + if ( + u.pathname === `/api/gallery/${st.info.gid}` && + req.method === "GET" + ) return true; + if (u.pathname === "/api/tag/rows" && req.method === "GET") { + return true; + } + // Follow API need extra checks. + if ( + u.pathname.match(/^\/api\/file\/\d+$/) && + req.method === "GET" + ) return true; + if ( + u.pathname.match(/^\/api\/thumbnail\/\d+$/) && + req.method === "GET" + ) return true; + if ( + u.pathname.startsWith("/api/files/") && req.method === "GET" + ) return true; + return false; + }; + if (!check_g()) return check(); + } else { + return check(); + } + ctx.state.is_from_cookie = false; + ctx.state.shared_token = st; + return true; + } + if (t.expired.getTime() < now) return check(); const user = m.db.get_user(t.uid); if (!user) { m.db.delete_token(token); diff --git a/routes/api/file/[id].ts b/routes/api/file/[id].ts index a3edde7..279f2ef 100644 --- a/routes/api/file/[id].ts +++ b/routes/api/file/[id].ts @@ -9,9 +9,14 @@ import pbkdf2Hmac from "pbkdf2-hmac"; import { encodeBase64 as encode } from "@std/encoding/base64"; import { get_host, return_data, return_error } from "../../../server/utils.ts"; import type { EhFileExtend } from "../../../server/files.ts"; -import { User, UserPermission } from "../../../db.ts"; +import { + SharedToken, + SharedTokenType, + User, + UserPermission, +} from "../../../db.ts"; import { SortableURLSearchParams } from "../../../server/SortableURLSearchParams.ts"; -import { isNumNaN, parseBigInt } from "../../../utils.ts"; +import { compareNum, isNumNaN, parseBigInt } from "../../../utils.ts"; import { extname } from "@std/path"; export const handler: Handlers = { @@ -23,6 +28,7 @@ export const handler: Handlers = { ) { return return_error(403, "Permission denied."); } + const st = ctx.state.shared_token; const u = new URL(req.url); const m = get_task_manager(); const token = u.searchParams.get("token"); @@ -51,9 +57,20 @@ export const handler: Handlers = { } const f = m.db.get_file(id); if (!f) { + if (st && st.type == SharedTokenType.Gallery) { + if (data) return return_error(403, "Permission denied."); + return new Response("Permission denied.", { status: 403 }); + } if (data) return return_error(404, "File not found."); return new Response("File not found.", { status: 404 }); } + if (st && st.type == SharedTokenType.Gallery) { + const pmetas = m.db.get_pmeta_by_token_only(f.token); + if (!pmetas.some((m) => !compareNum(m.gid, st.info.gid))) { + if (data) return return_error(403, "Permission denied."); + return new Response("Permission denied.", { status: 403 }); + } + } if (data) { return return_data({ id: f.id, diff --git a/routes/api/files/[token].ts b/routes/api/files/[token].ts index 1f58910..a874b9b 100644 --- a/routes/api/files/[token].ts +++ b/routes/api/files/[token].ts @@ -2,7 +2,13 @@ import { Handlers } from "$fresh/server.ts"; import { get_task_manager } from "../../../server.ts"; import type { EhFiles } from "../../../server/files.ts"; import { return_data, return_error } from "../../../server/utils.ts"; -import { User, UserPermission } from "../../../db.ts"; +import { + SharedToken, + SharedTokenType, + User, + UserPermission, +} from "../../../db.ts"; +import { compareNum } from "../../../utils.ts"; export const handler: Handlers = { GET(_req, ctx) { @@ -10,6 +16,7 @@ export const handler: Handlers = { if (u && !u.is_admin && !(u.permissions & UserPermission.ReadGallery)) { return return_error(403, "Permission denied."); } + const st = ctx.state.shared_token; const tokens = ctx.params.token.split(","); const m = get_task_manager(); const enable_server_timing = m.cfg.enable_server_timing; @@ -17,6 +24,12 @@ export const handler: Handlers = { const headers: HeadersInit = {}; const data: EhFiles = {}; for (const token of tokens) { + if (st && st.type == SharedTokenType.Gallery) { + const pmetas = m.db.get_pmeta_by_token_only(token); + if (!pmetas.some((m) => !compareNum(m.gid, st.info.gid))) { + return return_error(403, "Permission denied."); + } + } data[token] = m.db.get_files(token).map((d) => { /**@ts-ignore */ delete d.path; diff --git a/routes/api/shared_token.ts b/routes/api/shared_token.ts new file mode 100644 index 0000000..792ed15 --- /dev/null +++ b/routes/api/shared_token.ts @@ -0,0 +1,64 @@ +import { Handlers } from "$fresh/server.ts"; +import { exists } from "@std/fs/exists"; +import { + SharedToken, + SharedTokenType, + User, + UserPermission, +} from "../../db.ts"; +import { get_task_manager } from "../../server.ts"; +import { + get_string, + parse_big_int, + parse_int, +} from "../../server/parse_form.ts"; +import { get_host, return_data, return_error } from "../../server/utils.ts"; + +export const handler: Handlers = { + GET(_req, ctx) { + const st = ctx.state.shared_token; + if (!st) return return_error(1, "No token."); + return return_data(st); + }, + async PUT(req, ctx) { + const user = ctx.state.user; + let form: FormData | undefined; + try { + form = await req.formData(); + } catch (_) { + return return_error(400, "Bad Request"); + } + const typ = await get_string(form.get("type")); + const expired = await parse_int(form.get("expired"), null); + if (typ == "gallery") { + if ( + user && !user.is_admin && + !(user.permissions & UserPermission.ShareGallery) + ) { + return return_error(403, "Permission denied."); + } + const gid = await parse_big_int(form.get("gid"), null); + if (!gid) return return_error(2, "gid not specified."); + const m = get_task_manager(); + const st = m.db.add_shared_token(SharedTokenType.Gallery, { + gid: gid, + }, expired ? new Date(expired) : null); + let flutter_base = import.meta.resolve("../../static/flutter") + .slice(7); + if (Deno.build.os === "windows") { + flutter_base = flutter_base.slice(1); + } + if (m.cfg.flutter_frontend) { + flutter_base = m.cfg.flutter_frontend; + } + const existed = await exists(flutter_base); + const base = existed + ? `${get_host(req)}/flutter` + : "https://dev.ehf.lifegpc.com/#"; + const url = `${base}/gallery/${gid}?share=${st.token}`; + return return_data({ url, token: st }, 201); + } else { + return return_error(1, "Unknown type"); + } + }, +}; diff --git a/routes/api/thumbnail/[id].ts b/routes/api/thumbnail/[id].ts index d531fc5..75bf261 100644 --- a/routes/api/thumbnail/[id].ts +++ b/routes/api/thumbnail/[id].ts @@ -10,7 +10,7 @@ import { ThumbnailConfig, ThumbnailGenMethod, } from "../../../thumbnail/base.ts"; -import { isNumNaN, parseBigInt, sure_dir } from "../../../utils.ts"; +import { compareNum, isNumNaN, parseBigInt, sure_dir } from "../../../utils.ts"; import { ThumbnailMethod } from "../../../config.ts"; import { fb_generate_thumbnail } from "../../../thumbnail/ffmpeg_binary.ts"; import { @@ -22,7 +22,12 @@ import pbkdf2Hmac from "pbkdf2-hmac"; import { encodeBase64 as encode } from "@std/encoding/base64"; import { SortableURLSearchParams } from "../../../server/SortableURLSearchParams.ts"; import type * as FFMPEG_API from "../../../thumbnail/ffmpeg_api.ts"; -import { User, UserPermission } from "../../../db.ts"; +import { + SharedToken, + SharedTokenType, + User, + UserPermission, +} from "../../../db.ts"; let ffmpeg_api: typeof FFMPEG_API | undefined; @@ -35,6 +40,7 @@ export const handler: Handlers = { ) { return new Response("Permission denied", { status: 403 }); } + const st = ctx.state.shared_token; const id = parseBigInt(ctx.params.id); const m = get_task_manager(); const u = new URL(req.url); @@ -64,8 +70,17 @@ export const handler: Handlers = { await sure_dir(b); const f = m.db.get_file(id); if (!f) { + if (st && st.type == SharedTokenType.Gallery) { + return new Response("Permission denied.", { status: 403 }); + } return new Response("File not found.", { status: 404 }); } + if (st && st.type == SharedTokenType.Gallery) { + const pmetas = m.db.get_pmeta_by_token_only(f.token); + if (!pmetas.some((m) => !compareNum(m.gid, st.info.gid))) { + return new Response("Permission denied.", { status: 403 }); + } + } const max = await parse_int(u.searchParams.get("max"), 400); const width = await parse_int(u.searchParams.get("width"), null); const height = await parse_int(u.searchParams.get("height"), null);