From 469db6525da6abd085ba877abafad60fa77ce5fa Mon Sep 17 00:00:00 2001 From: Max Richter Date: Fri, 4 Aug 2023 22:35:25 +0200 Subject: [PATCH] feat: add authentication --- components/icons.tsx | 2 + deno.json | 4 +- dev.db | 0 fresh.gen.ts | 82 ++++++++++++----------- islands/KMenu/commands.ts | 25 +++++++ islands/KMenu/commands/add_movie_infos.ts | 2 + islands/KMenu/commands/create_article.ts | 6 +- islands/KMenu/commands/create_movie.ts | 9 ++- lib/auth.ts | 15 +++++ lib/cache/cache.ts | 17 ++++- lib/cache/documents.ts | 6 +- lib/cache/image.ts | 14 ++-- lib/crud.ts | 2 +- lib/db.ts | 18 +++++ lib/db/createSchema.ts | 32 +++++++++ lib/env.ts | 12 ++++ lib/errors.ts | 9 ++- lib/hash.ts | 9 --- lib/resource/articles.ts | 1 + lib/string.ts | 42 +++++++++++- lib/tmdb.ts | 4 +- lib/types.ts | 9 +++ package.json | 12 ++++ routes/_middleware.ts | 16 +++++ routes/api/articles/create/index.ts | 77 +++++++++++++++------ routes/api/auth/callback.ts | 75 +++++++++++++++++++++ routes/api/auth/login.ts | 29 ++++++++ routes/api/auth/logout.ts | 17 +++++ routes/api/movies/[name].ts | 6 ++ routes/api/movies/enhance/[name].ts | 17 +++-- routes/api/query/index.ts | 8 ++- routes/api/tmdb/query.ts | 13 ++-- routes/index.tsx | 2 + 33 files changed, 492 insertions(+), 100 deletions(-) create mode 100644 dev.db create mode 100644 lib/auth.ts create mode 100644 lib/db.ts create mode 100644 lib/db/createSchema.ts delete mode 100644 lib/hash.ts create mode 100644 package.json create mode 100644 routes/api/auth/callback.ts create mode 100644 routes/api/auth/login.ts create mode 100644 routes/api/auth/logout.ts diff --git a/components/icons.tsx b/components/icons.tsx index ba17ac1..6424c81 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -11,3 +11,5 @@ export { default as IconRefresh } from "https://deno.land/x/tabler_icons_tsx@0.0 export { default as IconCirclePlus } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/circle-plus.tsx"; export { default as IconCircleMinus } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/circle-minus.tsx"; export { default as IconLoader2 } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/loader-2.tsx"; +export { default as IconLogin } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/login.tsx"; +export { default as IconLogout } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/logout.tsx"; diff --git a/deno.json b/deno.json index 776fe65..189829e 100755 --- a/deno.json +++ b/deno.json @@ -3,6 +3,7 @@ "tasks": { "start": "deno run -A --watch=static/,routes/ dev.ts", "debug": "deno run --inspect-wait -A main.ts", + "generate-prisma": "deno run -A --unstable npm:prisma@^5.1 generate --help", "update": "deno run -A -r https://fresh.deno.dev/update ." }, "lint": { @@ -29,7 +30,8 @@ "@components": "./components", "@components/": "./components/", "@islands": "./islands", - "@islands/": "./islands/" + "@islands/": "./islands/", + "zod": "https://deno.land/x/zod@v3.21.4/mod.ts" }, "compilerOptions": { "jsx": "react-jsx", diff --git a/dev.db b/dev.db new file mode 100644 index 0000000..e69de29 diff --git a/fresh.gen.ts b/fresh.gen.ts index 2188ec2..6b12e21 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -8,25 +8,28 @@ import * as $2 from "./routes/_middleware.ts"; import * as $3 from "./routes/api/articles/[name].ts"; import * as $4 from "./routes/api/articles/create/index.ts"; import * as $5 from "./routes/api/articles/index.ts"; -import * as $6 from "./routes/api/cache/index.ts"; -import * as $7 from "./routes/api/images/index.ts"; -import * as $8 from "./routes/api/index.ts"; -import * as $9 from "./routes/api/movies/[name].ts"; -import * as $10 from "./routes/api/movies/enhance/[name].ts"; -import * as $11 from "./routes/api/movies/index.ts"; -import * as $12 from "./routes/api/query/index.ts"; -import * as $13 from "./routes/api/recipes/[name].ts"; -import * as $14 from "./routes/api/recipes/index.ts"; -import * as $15 from "./routes/api/tmdb/[id].ts"; -import * as $16 from "./routes/api/tmdb/credits/[id].ts"; -import * as $17 from "./routes/api/tmdb/query.ts"; -import * as $18 from "./routes/articles/[name].tsx"; -import * as $19 from "./routes/articles/index.tsx"; -import * as $20 from "./routes/index.tsx"; -import * as $21 from "./routes/movies/[name].tsx"; -import * as $22 from "./routes/movies/index.tsx"; -import * as $23 from "./routes/recipes/[name].tsx"; -import * as $24 from "./routes/recipes/index.tsx"; +import * as $6 from "./routes/api/auth/callback.ts"; +import * as $7 from "./routes/api/auth/login.ts"; +import * as $8 from "./routes/api/auth/logout.ts"; +import * as $9 from "./routes/api/cache/index.ts"; +import * as $10 from "./routes/api/images/index.ts"; +import * as $11 from "./routes/api/index.ts"; +import * as $12 from "./routes/api/movies/[name].ts"; +import * as $13 from "./routes/api/movies/enhance/[name].ts"; +import * as $14 from "./routes/api/movies/index.ts"; +import * as $15 from "./routes/api/query/index.ts"; +import * as $16 from "./routes/api/recipes/[name].ts"; +import * as $17 from "./routes/api/recipes/index.ts"; +import * as $18 from "./routes/api/tmdb/[id].ts"; +import * as $19 from "./routes/api/tmdb/credits/[id].ts"; +import * as $20 from "./routes/api/tmdb/query.ts"; +import * as $21 from "./routes/articles/[name].tsx"; +import * as $22 from "./routes/articles/index.tsx"; +import * as $23 from "./routes/index.tsx"; +import * as $24 from "./routes/movies/[name].tsx"; +import * as $25 from "./routes/movies/index.tsx"; +import * as $26 from "./routes/recipes/[name].tsx"; +import * as $27 from "./routes/recipes/index.tsx"; import * as $$0 from "./islands/Counter.tsx"; import * as $$1 from "./islands/IngredientsList.tsx"; import * as $$2 from "./islands/KMenu.tsx"; @@ -44,25 +47,28 @@ const manifest = { "./routes/api/articles/[name].ts": $3, "./routes/api/articles/create/index.ts": $4, "./routes/api/articles/index.ts": $5, - "./routes/api/cache/index.ts": $6, - "./routes/api/images/index.ts": $7, - "./routes/api/index.ts": $8, - "./routes/api/movies/[name].ts": $9, - "./routes/api/movies/enhance/[name].ts": $10, - "./routes/api/movies/index.ts": $11, - "./routes/api/query/index.ts": $12, - "./routes/api/recipes/[name].ts": $13, - "./routes/api/recipes/index.ts": $14, - "./routes/api/tmdb/[id].ts": $15, - "./routes/api/tmdb/credits/[id].ts": $16, - "./routes/api/tmdb/query.ts": $17, - "./routes/articles/[name].tsx": $18, - "./routes/articles/index.tsx": $19, - "./routes/index.tsx": $20, - "./routes/movies/[name].tsx": $21, - "./routes/movies/index.tsx": $22, - "./routes/recipes/[name].tsx": $23, - "./routes/recipes/index.tsx": $24, + "./routes/api/auth/callback.ts": $6, + "./routes/api/auth/login.ts": $7, + "./routes/api/auth/logout.ts": $8, + "./routes/api/cache/index.ts": $9, + "./routes/api/images/index.ts": $10, + "./routes/api/index.ts": $11, + "./routes/api/movies/[name].ts": $12, + "./routes/api/movies/enhance/[name].ts": $13, + "./routes/api/movies/index.ts": $14, + "./routes/api/query/index.ts": $15, + "./routes/api/recipes/[name].ts": $16, + "./routes/api/recipes/index.ts": $17, + "./routes/api/tmdb/[id].ts": $18, + "./routes/api/tmdb/credits/[id].ts": $19, + "./routes/api/tmdb/query.ts": $20, + "./routes/articles/[name].tsx": $21, + "./routes/articles/index.tsx": $22, + "./routes/index.tsx": $23, + "./routes/movies/[name].tsx": $24, + "./routes/movies/index.tsx": $25, + "./routes/recipes/[name].tsx": $26, + "./routes/recipes/index.tsx": $27, }, islands: { "./islands/Counter.tsx": $$0, diff --git a/islands/KMenu/commands.ts b/islands/KMenu/commands.ts index a0c7dfa..b2577c7 100644 --- a/islands/KMenu/commands.ts +++ b/islands/KMenu/commands.ts @@ -2,6 +2,7 @@ import { Menu } from "@islands/KMenu/types.ts"; import { addMovieInfos } from "@islands/KMenu/commands/add_movie_infos.ts"; import { createNewMovie } from "@islands/KMenu/commands/create_movie.ts"; import { createNewArticle } from "@islands/KMenu/commands/create_article.ts"; +import { getCookie } from "@lib/string.ts"; export const menus: Record = { main: { @@ -18,6 +19,30 @@ export const menus: Record = { state.activeState.value = "normal"; state.visible.value = false; }, + visible: () => { + if (!getCookie("session_cookie")) return false; + return true; + }, + }, + { + title: "Login", + icon: "IconLogin", + cb: () => { + window.location.pathname = "/api/auth/login"; + }, + visible: () => { + return !getCookie("session_cookie"); + }, + }, + { + title: "Logout", + icon: "IconLogout", + cb: () => { + window.location.pathname = "/api/auth/logout"; + }, + visible: () => { + return !!getCookie("session_cookie"); + }, }, createNewArticle, createNewMovie, diff --git a/islands/KMenu/commands/add_movie_infos.ts b/islands/KMenu/commands/add_movie_infos.ts index ea56675..7b66e91 100644 --- a/islands/KMenu/commands/add_movie_infos.ts +++ b/islands/KMenu/commands/add_movie_infos.ts @@ -1,6 +1,7 @@ import { MenuEntry } from "@islands/KMenu/types.ts"; import { Movie } from "@lib/resource/movies.ts"; import { TMDBMovie } from "@lib/types.ts"; +import { getCookie } from "@lib/string.ts"; export const addMovieInfos: MenuEntry = { title: "Add Movie infos", @@ -43,6 +44,7 @@ export const addMovieInfos: MenuEntry = { }, visible: () => { const loc = globalThis["location"]; + if (!getCookie("session_cookie")) return false; return loc?.pathname?.includes("movie"); }, }; diff --git a/islands/KMenu/commands/create_article.ts b/islands/KMenu/commands/create_article.ts index 5ddfba1..ab984fe 100644 --- a/islands/KMenu/commands/create_article.ts +++ b/islands/KMenu/commands/create_article.ts @@ -1,5 +1,6 @@ import { MenuEntry } from "@islands/KMenu/types.ts"; import { fetchStream, isValidUrl } from "@lib/helpers.ts"; +import { getCookie } from "@lib/string.ts"; export const createNewArticle: MenuEntry = { title: "Create new article", @@ -35,5 +36,8 @@ export const createNewArticle: MenuEntry = { } }); }, - visible: () => true, + visible: () => { + if (!getCookie("session_cookie")) return false; + return true; + }, }; diff --git a/islands/KMenu/commands/create_movie.ts b/islands/KMenu/commands/create_movie.ts index 2462803..1d308fb 100644 --- a/islands/KMenu/commands/create_movie.ts +++ b/islands/KMenu/commands/create_movie.ts @@ -2,6 +2,7 @@ import { MenuEntry } from "@islands/KMenu/types.ts"; import { TMDBMovie } from "@lib/types.ts"; import { debounce } from "@lib/helpers.ts"; import { Movie } from "@lib/resource/movies.ts"; +import { getCookie } from "@lib/string.ts"; export const createNewMovie: MenuEntry = { title: "Create new movie", @@ -40,8 +41,6 @@ export const createNewMovie: MenuEntry = { const movies = await response.json() as TMDBMovie[]; - console.log({ query, currentQuery, movies }); - if (query !== currentQuery) return; state.menus["input_link"] = { @@ -55,6 +54,7 @@ export const createNewMovie: MenuEntry = { method: "POST", }); const movie = await response.json() as Movie; + unsub(); window.location.href = "/movies/" + movie.name; }, }; @@ -68,5 +68,8 @@ export const createNewMovie: MenuEntry = { search(value); }); }, - visible: () => true, + visible: () => { + if (!getCookie("session_cookie")) return false; + return true; + }, }; diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..07b7ba4 --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,15 @@ +import { OAuth2Client } from "https://deno.land/x/oauth2_client@v1.0.2/mod.ts"; +import { + GITEA_CLIENT_ID, + GITEA_CLIENT_SECRET, + GITEA_REDIRECT_URL, + GITEA_SERVER, +} from "@lib/env.ts"; + +export const oauth2Client = new OAuth2Client({ + clientId: GITEA_CLIENT_ID, + clientSecret: GITEA_CLIENT_SECRET, + authorizationEndpointUri: `${GITEA_SERVER}/login/oauth/authorize`, + tokenUri: `${GITEA_SERVER}/login/oauth/access_token`, + redirectUri: GITEA_REDIRECT_URL, +}); diff --git a/lib/cache/cache.ts b/lib/cache/cache.ts index fb6ec21..049a17d 100644 --- a/lib/cache/cache.ts +++ b/lib/cache/cache.ts @@ -10,7 +10,7 @@ const REDIS_HOST = Deno.env.get("REDIS_HOST"); const REDIS_PASS = Deno.env.get("REDIS_PASS") || ""; const REDIS_PORT = Deno.env.get("REDIS_PORT"); -async function createCache(): Promise | Redis> { +async function createCache(): Promise { if (REDIS_HOST) { const conf: RedisConnectOptions = { hostname: REDIS_HOST, @@ -32,6 +32,13 @@ async function createCache(): Promise | Redis> { const mockRedis = new Map(); return { + async keys() { + return mockRedis.keys(); + }, + async delete(key: string) { + mockRedis.delete(key); + return key; + }, async set(key: string, value: RedisValue) { mockRedis.set(key, value); return value.toString(); @@ -74,6 +81,14 @@ type RedisOptions = { expires?: number; }; +export function del(key: string) { + return cache.del(key); +} + +export function keys(prefix: string) { + return cache.keys(prefix); +} + export async function set( id: string, content: T, diff --git a/lib/cache/documents.ts b/lib/cache/documents.ts index efa9e27..bdde90b 100644 --- a/lib/cache/documents.ts +++ b/lib/cache/documents.ts @@ -1,7 +1,7 @@ import { Document } from "@lib/documents.ts"; import * as cache from "@lib/cache/cache.ts"; -const CACHE_INTERVAL = 20; // 5 seconds; +const CACHE_INTERVAL = 60; const CACHE_KEY = "documents"; export async function getDocuments() { @@ -19,12 +19,12 @@ export function setDocuments(documents: Document[]) { } export function getDocument(id: string) { - return cache.get(CACHE_KEY + "/" + id); + return cache.get(CACHE_KEY + ":" + id.replaceAll("/", ":")); } export async function setDocument(id: string, content: string) { await cache.set( - CACHE_KEY + "/" + id, + CACHE_KEY + ":" + id.replaceAll("/", ":"), content, { expires: CACHE_INTERVAL }, ); diff --git a/lib/cache/image.ts b/lib/cache/image.ts index 7a3ff9a..d1ffced 100644 --- a/lib/cache/image.ts +++ b/lib/cache/image.ts @@ -1,4 +1,4 @@ -import { hash } from "@lib/hash.ts"; +import { hash } from "@lib/string.ts"; import * as cache from "@lib/cache/cache.ts"; import { ImageMagick } from "https://deno.land/x/imagemagick_deno@0.0.25/mod.ts"; @@ -13,10 +13,12 @@ const CACHE_KEY = "images"; function getCacheKey({ url: _url, width, height }: ImageCacheOptions) { const url = new URL(_url); - return `${CACHE_KEY}/${url.hostname}/${url.pathname}/${width}/${height}` + return `${CACHE_KEY}:${url.hostname}:${ + url.pathname.replaceAll("/", ":") + }:${width}:${height}` .replace( - "//", - "/", + "::", + ":", ); } @@ -44,7 +46,7 @@ export async function getImage({ url, width, height }: ImageCacheOptions) { ? JSON.parse(pointerCacheRaw) : pointerCacheRaw; - const imageContent = await cache.get(pointerCache.id, true); + const imageContent = await cache.get(`image:${pointerCache.id}`, true); if (!imageContent) return; return { @@ -68,7 +70,7 @@ export async function setImage( const cacheKey = getCacheKey({ url, width, height }); const pointerId = await hash(cacheKey); - await cache.set(pointerId, clone); + await cache.set(`image:${pointerId}`, clone); cache.expire(pointerId, 60 * 60 * 24); cache.expire(cacheKey, 60 * 60 * 24); diff --git a/lib/crud.ts b/lib/crud.ts index 76fa5db..4f998b7 100644 --- a/lib/crud.ts +++ b/lib/crud.ts @@ -14,7 +14,7 @@ export function createCrud( }, ) { function pathFromId(id: string) { - return `${prefix}${id}.md`; + return `${prefix}${id.replaceAll(":", "")}.md`; } async function read(id: string) { diff --git a/lib/db.ts b/lib/db.ts new file mode 100644 index 0000000..754c7a2 --- /dev/null +++ b/lib/db.ts @@ -0,0 +1,18 @@ +import z from "https://deno.land/x/zod@v3.21.4/index.ts"; +import { createSchema } from "@lib/db/createSchema.ts"; + +const UserSchema = z.object({ + id: z.string().optional().default(() => crypto.randomUUID()), + createdAt: z.date().default(() => new Date()), + email: z.string().email(), + name: z.string(), +}); +export const userDB = createSchema("user", UserSchema); + +const SessionSchema = z.object({ + id: z.string().default(() => crypto.randomUUID()), + createdAt: z.date().default(() => new Date()), + expiresAt: z.date().default(() => new Date()), + userId: z.string(), +}); +export const sessionDB = createSchema("session", SessionSchema); diff --git a/lib/db/createSchema.ts b/lib/db/createSchema.ts new file mode 100644 index 0000000..5db35a8 --- /dev/null +++ b/lib/db/createSchema.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import * as cache from "@lib/cache/cache.ts"; + +export function createSchema(name: string, schema: T) { + type Data = z.infer; + return { + async create(input: Omit): Promise { + const data = schema.safeParse(input); + if (data.success) { + const d = data.data; + const id = d["id"]; + if (!id) return d; + await cache.set(`${name}:${id}`, JSON.stringify(d)); + return d; + } + return null; + }, + async findAll(): Promise { + const keys = await cache.keys(`${name}:*`); + return Promise.all(keys.map((k) => { + return cache.get(k); + })).then((values) => values.map((v) => JSON.parse(v || "null"))); + }, + async find(id: string) { + const k = await cache.get(`${name}:${id}`); + return JSON.parse(k || "null") as Data | null; + }, + delete(id: string) { + return cache.del(`${name}:${id}`); + }, + }; +} diff --git a/lib/env.ts b/lib/env.ts index cdc73c2..de3d20d 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -4,3 +4,15 @@ export const REDIS_PASS = Deno.env.get("REDIS_PASS"); export const TMDB_API_KEY = Deno.env.get("TMDB_API_KEY"); export const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY"); export const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY"); + +export const GITEA_SERVER = Deno.env.get("GITEA_SERVER"); +export const GITEA_CLIENT_ID = Deno.env.get("GITEA_CLIENT_ID"); +export const GITEA_CLIENT_SECRET = Deno.env.get("GITEA_CLIENT_SECRET"); +export const GITEA_REDIRECT_URL = Deno.env.get("GITEA_REDIRECT_URL"); + +export const DATABASE_URL = Deno.env.get("DATABASE_URL") || "dev.db"; + +const duration = Deno.env.get("SESSION_DURATION"); +export const SESSION_DURATION = duration ? +duration : (60 * 60 * 24); + +export const JWT_SECRET = Deno.env.get("JWT_SECRET"); diff --git a/lib/errors.ts b/lib/errors.ts index 93826ac..0ef2f75 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -22,4 +22,11 @@ class BadRequestError extends DomainError { } } -export { BadRequestError, DomainError, NotFoundError }; +class AccessDeniedError extends DomainError { + status = 403; + constructor(public statusText = "Access Denied") { + super(); + } +} + +export { AccessDeniedError, BadRequestError, DomainError, NotFoundError }; diff --git a/lib/hash.ts b/lib/hash.ts deleted file mode 100644 index 3db7dff..0000000 --- a/lib/hash.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function hash(message: string) { - const data = new TextEncoder().encode(message); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join( - "", - ); - return hashHex; -} diff --git a/lib/resource/articles.ts b/lib/resource/articles.ts index 8bb44a7..23a80d1 100644 --- a/lib/resource/articles.ts +++ b/lib/resource/articles.ts @@ -15,6 +15,7 @@ export type Article = { status: "finished" | "not-finished"; date: Date; link: string; + image?: string; author?: string; rating?: number; }; diff --git a/lib/string.ts b/lib/string.ts index 7b7a206..8160220 100644 --- a/lib/string.ts +++ b/lib/string.ts @@ -13,6 +13,8 @@ export function safeFileName(inputString: string): string { // Remove characters that are not safe for file names fileName = fileName.replace(/[^\w.-]/g, ""); + fileName = fileName.replaceAll(":", ""); + return fileName; } @@ -34,7 +36,9 @@ export function extractHashTags(inputString: string) { export const isYoutubeLink = (link: string) => { try { const url = new URL(link); - return ["youtu.be", "youtube.com","www.youtube.com" ].includes(url.hostname); + return ["youtu.be", "youtube.com", "www.youtube.com"].includes( + url.hostname, + ); } catch (_err) { return false; } @@ -52,3 +56,39 @@ export function extractYoutubeId(link: string) { return url.pathname.replace(/^\//, ""); } + +export async function hash(message: string) { + const data = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join( + "", + ); + return hashHex; +} +// Helper function to calculate SHA-256 hash +export async function sha256(input: string) { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + return base64urlencode(new Uint8Array(hashBuffer)); +} + +// Helper function to encode a byte array as a URL-safe base64 string +function base64urlencode(data: Uint8Array) { + const base64 = btoa(String.fromCharCode(...data)); + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +export function getCookie(name: string): string | null { + if (typeof document === "undefined") return null; + const nameLenPlus = name.length + 1; + return document.cookie + .split(";") + .map((c) => c.trim()) + .filter((cookie) => { + return cookie.substring(0, nameLenPlus) === `${name}=`; + }) + .map((cookie) => { + return decodeURIComponent(cookie.substring(nameLenPlus)); + })[0] || null; +} diff --git a/lib/tmdb.ts b/lib/tmdb.ts index 46fbfd7..74d5fb9 100644 --- a/lib/tmdb.ts +++ b/lib/tmdb.ts @@ -15,7 +15,7 @@ export function getMovieCredits(id: number) { } export async function getMoviePoster(id: string): Promise { - const cachedPoster = await cache.get("posters/" + id); + const cachedPoster = await cache.get("posters:" + id); if (cachedPoster) return cachedPoster as ArrayBuffer; @@ -23,6 +23,6 @@ export async function getMoviePoster(id: string): Promise { const response = await fetch(posterUrl); const poster = await response.arrayBuffer(); - cache.set(`posters/${id}`, new Uint8Array()); + cache.set(`posters:${id}`, new Uint8Array()); return poster; } diff --git a/lib/types.ts b/lib/types.ts index 4e92771..5631f8e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -14,3 +14,12 @@ export interface TMDBMovie { vote_average: number; vote_count: number; } + +export interface GiteaOauthUser { + sub: string; + name: string; + preferred_username: string; + email: string; + picture: string; + groups: any; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6f1c6a7 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "silver-api", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/routes/_middleware.ts b/routes/_middleware.ts index 8733182..efffd5a 100644 --- a/routes/_middleware.ts +++ b/routes/_middleware.ts @@ -1,6 +1,10 @@ //routes/middleware-error-handler/_middleware.ts import { MiddlewareHandlerContext } from "$fresh/server.ts"; import { DomainError } from "@lib/errors.ts"; +import { getCookies } from "https://deno.land/std@0.197.0/http/cookie.ts"; +import { decode, verify } from "https://deno.land/x/djwt@v2.2/mod.ts"; +import { sessionDB, userDB } from "@lib/db.ts"; +import { JWT_SECRET } from "@lib/env.ts"; export async function handler( _req: Request, @@ -8,6 +12,18 @@ export async function handler( ) { try { ctx.state.flag = true; + const allCookies = getCookies(_req.headers); + const sessionCookie = allCookies["session_cookie"]; + if (!ctx.state.session && sessionCookie && JWT_SECRET) { + try { + const payload = await verify(sessionCookie, JWT_SECRET, "HS512"); + if (payload) { + ctx.state.session = payload; + } + } catch (_err) { + // + } + } return await ctx.next(); } catch (error) { console.error(error); diff --git a/routes/api/articles/create/index.ts b/routes/api/articles/create/index.ts index 698a478..5036c4a 100644 --- a/routes/api/articles/create/index.ts +++ b/routes/api/articles/create/index.ts @@ -1,15 +1,14 @@ import { Handlers } from "$fresh/server.ts"; import { Readability } from "https://cdn.skypack.dev/@mozilla/readability"; import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.38/deno-dom-wasm.ts"; -import { BadRequestError } from "@lib/errors.ts"; -import { createStreamResponse, isValidUrl, json } from "@lib/helpers.ts"; +import { AccessDeniedError, BadRequestError } from "@lib/errors.ts"; +import { createStreamResponse, isValidUrl } from "@lib/helpers.ts"; import * as openai from "@lib/openai.ts"; import tds from "https://cdn.skypack.dev/turndown@7.1.1"; -//import { gfm } from "https://cdn.skypack.dev/@guyplusplus/turndown-plugin-gfm@1.0.7"; import { Article, createArticle } from "@lib/resource/articles.ts"; import { getYoutubeVideoDetails } from "@lib/youtube.ts"; -import { extractYoutubeId, formatDate, isYoutubeLink } from "@lib/string.ts"; +import { extractYoutubeId, isYoutubeLink } from "@lib/string.ts"; const parser = new DOMParser(); @@ -34,6 +33,11 @@ async function processCreateArticle( const title = document?.querySelector("title")?.innerText; + const images: HTMLImageElement[] = []; + document?.querySelectorAll("img").forEach((img) => { + images.push(img as unknown as HTMLImageElement); + }); + const metaAuthor = document?.querySelector('meta[name="twitter:creator"]')?.getAttribute( "content", @@ -62,6 +66,19 @@ async function processCreateArticle( }); const url = new URL(fetchUrl); + + function makeUrlAbsolute(src: string) { + if (src.startsWith("/")) { + return `${url.origin}${src.replace(/$\//, "")}`; + } + + if (!src.startsWith("https://") && !src.startsWith("http://")) { + return `${url.origin.replace(/\/$/, "")}/${src.replace(/^\//, "")})`; + } + + return src; + } + service.addRule("fix image links", { filter: ["img"], replacement: function (_: string, node: HTMLImageElement) { @@ -69,17 +86,7 @@ async function processCreateArticle( const alt = node.getAttribute("alt") || ""; if (!src || src.startsWith("data:image")) return ""; - if (src.startsWith("/")) { - return `![${alt}](${url.origin}${src.replace(/$\//, "")})`; - } - - if (!src.startsWith("https://") && !src.startsWith("http://")) { - return `![${alt}](${url.origin.replace(/\/$/, "")}/${ - src.replace(/^\//, "") - })`; - } - - return `![${alt}](${src})`; + return `![${alt}](${makeUrlAbsolute(src)})`; }, }); service.addRule("fix normal links", { @@ -119,19 +126,40 @@ async function processCreateArticle( const id = shortTitle || title || ""; + const meta: Article["meta"] = { + author: (author || "").replace("@", "twitter:"), + link: fetchUrl, + status: "not-finished", + date: new Date(), + }; + + const largestImage = images.filter((img) => { + const src = img.getAttribute("src"); + return !!src && !src.startsWith("data:"); + }).sort((a, b) => { + const aSize = +(a.getAttribute("width") || 0) + + +(a.getAttribute("height") || 0); + const bSize = +(b.getAttribute("width") || 0) + + +(b.getAttribute("height") || 0); + return aSize > bSize ? -1 : 1; + })[0]; + const newArticle = { + type: "article", id, name: title || "", content: markdown, tags: tags || [], - meta: { - author: (author || "").replace("@", "twitter:"), - link: fetchUrl, - status: "not-finished", - date: new Date(), - }, + meta, } as const; + if (largestImage) { + const src = makeUrlAbsolute(largestImage.getAttribute("src") || ""); + if (src) { + meta.image = src; + } + } + streamResponse.enqueue("finished processing"); await createArticle(newArticle); @@ -181,7 +209,12 @@ async function processCreateYoutubeVideo( } export const handler: Handlers = { - GET(req) { + GET(req, ctx) { + const session = ctx.state.session; + if (!session) { + throw new AccessDeniedError(); + } + const url = new URL(req.url); const fetchUrl = url.searchParams.get("url"); diff --git a/routes/api/auth/callback.ts b/routes/api/auth/callback.ts new file mode 100644 index 0000000..8dbc306 --- /dev/null +++ b/routes/api/auth/callback.ts @@ -0,0 +1,75 @@ +import { Handlers } from "$fresh/server.ts"; +import { create, getNumericDate } from "https://deno.land/x/djwt@v2.2/mod.ts"; +import { oauth2Client } from "@lib/auth.ts"; +import { + getCookies, + setCookie, +} from "https://deno.land/std@0.197.0/http/cookie.ts"; +import { codeChallengeMap } from "./login.ts"; +import { GITEA_SERVER, JWT_SECRET, SESSION_DURATION } from "@lib/env.ts"; +import { userDB } from "@lib/db.ts"; +import { GiteaOauthUser } from "@lib/types.ts"; +import { BadRequestError } from "@lib/errors.ts"; + +export const handler: Handlers = { + async GET(request, ctx) { + if (!JWT_SECRET) { + throw new BadRequestError(); + } + + // Exchange the authorization code for an access token + const cookies = getCookies(request.headers); + + const codeVerifier = codeChallengeMap.get(cookies["code_challenge"]); + + const tokens = await oauth2Client.code.getToken(request.url, { + codeVerifier, + }); + + // Use the access token to make an authenticated API request + const userInfo = `${GITEA_SERVER}/login/oauth/userinfo`; + const userResponse = await fetch(userInfo, { + headers: { + Authorization: `token ${tokens.accessToken}`, + }, + }); + + const oauthUser = await userResponse.json() as GiteaOauthUser; + + const allUsers = await userDB.findAll(); + let user = allUsers.find((u) => u.name === oauthUser.name); + + if (!user) { + user = await userDB.create({ + createdAt: new Date(), + email: oauthUser.email, + name: oauthUser.name, + }); + } + + const jwt = await create({ alg: "HS512", type: "JWT" }, { + id: user.id, + name: user.name, + exp: getNumericDate(SESSION_DURATION), + }, JWT_SECRET); + + const headers = new Headers({ + location: "/", + }); + + setCookie(headers, { + name: "session_cookie", + value: jwt, + path: "/", + maxAge: SESSION_DURATION, + httpOnly: false, + secure: true, + sameSite: "Lax", + }); + + return new Response(null, { + headers, + status: 302, + }); + }, +}; diff --git a/routes/api/auth/login.ts b/routes/api/auth/login.ts new file mode 100644 index 0000000..303de2f --- /dev/null +++ b/routes/api/auth/login.ts @@ -0,0 +1,29 @@ +import { Handlers } from "$fresh/server.ts"; +import { oauth2Client } from "@lib/auth.ts"; +import { sha256 } from "@lib/string.ts"; +import { setCookie } from "https://deno.land/std@0.197.0/http/cookie.ts"; + +export const codeChallengeMap = new Map(); + +export const handler: Handlers = { + async GET() { + const { codeVerifier, uri } = await oauth2Client.code.getAuthorizationUri(); + + const codeChallenge = uri.searchParams.get("code_challenge"); + if (!codeChallenge) return new Response(); + + codeChallengeMap.set(codeChallenge, codeVerifier); + + const headers = new Headers(); + setCookie(headers, { + name: "code_challenge", + value: codeChallenge, + }); + headers.append("location", uri.href); + + return new Response(null, { + headers, + status: 302, + }); + }, +}; diff --git a/routes/api/auth/logout.ts b/routes/api/auth/logout.ts new file mode 100644 index 0000000..3a4cbca --- /dev/null +++ b/routes/api/auth/logout.ts @@ -0,0 +1,17 @@ +import { deleteCookie } from "https://deno.land/std@0.197.0/http/cookie.ts"; +import { Handlers } from "$fresh/server.ts"; + +export const handler: Handlers = { + GET() { + const headers = new Headers(); + headers.append("location", "/"); + deleteCookie(headers, "session_cookie", { + path: "/", + }); + + return new Response(null, { + headers, + status: 302, + }); + }, +}; diff --git a/routes/api/movies/[name].ts b/routes/api/movies/[name].ts index 7e65c90..39767d4 100644 --- a/routes/api/movies/[name].ts +++ b/routes/api/movies/[name].ts @@ -5,6 +5,7 @@ import * as tmdb from "@lib/tmdb.ts"; import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts"; import { safeFileName } from "@lib/string.ts"; import { createDocument } from "@lib/documents.ts"; +import { AccessDeniedError } from "@lib/errors.ts"; export const handler: Handlers = { async GET(_, ctx) { @@ -12,6 +13,11 @@ export const handler: Handlers = { return json(movie); }, async POST(_, ctx) { + const session = ctx.state.session; + if (!session) { + throw new AccessDeniedError(); + } + const tmdbId = parseInt(ctx.params.name); const movieDetails = await tmdb.getMovie(tmdbId); diff --git a/routes/api/movies/enhance/[name].ts b/routes/api/movies/enhance/[name].ts index 49157fb..5ac7947 100644 --- a/routes/api/movies/enhance/[name].ts +++ b/routes/api/movies/enhance/[name].ts @@ -10,7 +10,7 @@ import * as tmdb from "@lib/tmdb.ts"; import { parse, stringify } from "https://deno.land/std@0.194.0/yaml/mod.ts"; import { formatDate, safeFileName } from "@lib/string.ts"; import { json } from "@lib/helpers.ts"; -import { BadRequestError } from "@lib/errors.ts"; +import { AccessDeniedError, BadRequestError } from "@lib/errors.ts"; async function updateMovieMetadata( name: string, @@ -49,13 +49,18 @@ async function updateMovieMetadata( } const POST = async ( - _req: Request, - _ctx: HandlerContext, + req: Request, + ctx: HandlerContext, ): Promise => { - const movie = await getMovie(_ctx.params.name); + const movie = await getMovie(ctx.params.name); - const body = await _req.json(); - const name = _ctx.params.name; + const session = ctx.state.session; + if (!session) { + throw new AccessDeniedError(); + } + + const body = await req.json(); + const name = ctx.params.name; const { tmdbId } = body; if (!name || !tmdbId) { throw new BadRequestError(); diff --git a/routes/api/query/index.ts b/routes/api/query/index.ts index ab19f6a..d1360e5 100644 --- a/routes/api/query/index.ts +++ b/routes/api/query/index.ts @@ -3,6 +3,7 @@ import { json } from "@lib/helpers.ts"; import { getAllMovies, Movie } from "@lib/resource/movies.ts"; import { Article, getAllArticles } from "@lib/resource/articles.ts"; import { getAllRecipes, Recipe } from "@lib/resource/recipes.ts"; +import { AccessDeniedError } from "@lib/errors.ts"; const isResource = ( item: Movie | Article | Recipe | boolean, @@ -11,7 +12,12 @@ const isResource = ( }; export const handler: Handlers = { - async GET(req) { + async GET(req, ctx) { + const session = ctx.state.session; + if (!session) { + throw new AccessDeniedError(); + } + const url = new URL(req.url); const types = url.searchParams.get("type")?.split(", "); diff --git a/routes/api/tmdb/query.ts b/routes/api/tmdb/query.ts index aa80b9c..a47dd22 100644 --- a/routes/api/tmdb/query.ts +++ b/routes/api/tmdb/query.ts @@ -1,7 +1,7 @@ import { HandlerContext, Handlers } from "$fresh/server.ts"; import { searchMovie } from "@lib/tmdb.ts"; import * as cache from "@lib/cache/cache.ts"; -import { BadRequestError } from "@lib/errors.ts"; +import { AccessDeniedError, BadRequestError } from "@lib/errors.ts"; import { json } from "@lib/helpers.ts"; type CachedMovieQuery = { @@ -12,10 +12,15 @@ type CachedMovieQuery = { const CACHE_INTERVAL = 1000 * 60 * 24 * 30; const GET = async ( - _req: Request, - _ctx: HandlerContext, + req: Request, + ctx: HandlerContext, ) => { - const u = new URL(_req.url); + const session = ctx.state.session; + if (!session) { + throw new AccessDeniedError(); + } + + const u = new URL(req.url); const query = u.searchParams.get("q"); diff --git a/routes/index.tsx b/routes/index.tsx index e56ab56..eeb82d1 100644 --- a/routes/index.tsx +++ b/routes/index.tsx @@ -3,6 +3,7 @@ import { MainLayout } from "@components/layouts/main.tsx"; import { Card } from "@components/Card.tsx"; import { PageProps } from "$fresh/server.ts"; import { menu } from "@lib/menus.ts"; +import { KMenu } from "@islands/KMenu.tsx"; export default function Home(props: PageProps) { return ( @@ -10,6 +11,7 @@ export default function Home(props: PageProps) { app +
{menu.map((m) => {