1
0
Fork 0
mirror of https://github.com/kou029w/quot.git synced 2025-01-18 08:05:08 +00:00

ログインできるようになった

This commit is contained in:
Nebel 2022-09-05 21:12:35 +09:00
parent 661b13e5ff
commit 2e05578e9a
17 changed files with 1401 additions and 77 deletions

49
app/controllers/config.ts Normal file
View file

@ -0,0 +1,49 @@
import type { HttpOptions } from "openid-client";
import crypto, { type KeyObject } from "node:crypto";
interface Config {
port: number;
apiUrl: URL;
apiEndpoint: string;
viewsDir: string;
rootUrl: URL;
openid: {
issuer: string;
client: {
client_id: string;
client_secret: string;
};
request: HttpOptions;
};
key: KeyObject;
}
export type { Config };
function defaultConfig(): Config {
const port = Number(process.env.PORT ?? "8080");
const rootUrl = new URL(
process.env.QUOT_ROOT_URL ?? `http://localhost:${port}/`
);
return {
port,
rootUrl,
apiUrl: new URL(process.env.QUOT_API_URL ?? "http://127.0.0.1:3000"),
apiEndpoint: process.env.QUOT_API_ENDPOINT ?? "/api",
viewsDir: "views",
openid: {
issuer: process.env.QUOT_OPENID_ISSUER ?? "",
client: {
client_id: process.env.QUOT_OPENID_CLIENT_ID ?? "",
client_secret: process.env.QUOT_OPENID_CLIENT_SECRET ?? "",
},
request: { timeout: 5_000 },
},
key: crypto.createPrivateKey({
key: JSON.parse(process.env.QUOT_JWK ?? "{}"),
format: "jwk",
}),
};
}
export default defaultConfig;

21
app/controllers/index.ts Normal file
View file

@ -0,0 +1,21 @@
import fastify, { type FastifyInstance } from "fastify";
import defaultConfig, { type Config } from "./config";
import routes from "./routes/index";
declare module "fastify" {
interface FastifyInstance {
config: Config;
}
}
export function App(config: Config = defaultConfig()): FastifyInstance {
const app = fastify({ logger: true });
app.decorate("config", config).register(routes);
return app;
}
export async function start(app: FastifyInstance): Promise<string> {
await app.ready();
const address = await app.listen({ host: "::", port: app.config.port });
return address;
}

View file

@ -0,0 +1,11 @@
import type { FastifyInstance } from "fastify";
import httpProxy from "@fastify/http-proxy";
async function api(fastify: FastifyInstance) {
await fastify.register(httpProxy, {
prefix: "/api",
upstream: fastify.config.apiUrl.href,
});
}
export default api;

View file

@ -0,0 +1,14 @@
import type { FastifyInstance } from "fastify";
import api from "./api";
import login from "./login";
import views from "./views";
async function index(fastify: FastifyInstance) {
await Promise.all([
fastify.register(api),
fastify.register(login),
fastify.register(views),
]);
}
export default index;

View file

@ -0,0 +1,32 @@
import { custom, Issuer } from "openid-client";
import { SignJWT } from "jose";
import type { FastifyInstance } from "fastify";
async function login(fastify: FastifyInstance) {
custom.setHttpOptionsDefaults(fastify.config.openid.request);
const issuer = await Issuer.discover(fastify.config.openid.issuer);
const client = new issuer.Client(fastify.config.openid.client);
fastify.get("/login", async (request, reply) => {
const params = client.callbackParams(request.raw);
const redirect_uri = new URL("login", fastify.config.rootUrl).href;
if (!("code" in params)) {
const authorizationUrl = client.authorizationUrl({ redirect_uri });
return reply.redirect(authorizationUrl);
}
const tokens = await client.callback(redirect_uri, params);
const userUrl = new URL(issuer.metadata.issuer);
const userinfo = await client.userinfo(tokens);
userUrl.username = userinfo.sub;
const jwt = await new SignJWT({ role: "writer" })
.setProtectedHeader({ typ: "JWT", alg: "RS256" })
.setExpirationTime("30days")
.setSubject(userUrl.href)
.sign(fastify.config.key);
const url = new URL(fastify.config.rootUrl);
url.hash = new URLSearchParams({ jwt }).toString();
return reply.redirect(url.href);
});
}
export default login;

View file

@ -0,0 +1,33 @@
import type { FastifyInstance } from "fastify";
import httpProxy from "@fastify/http-proxy";
import esbuild from "esbuild";
async function views(fastify: FastifyInstance) {
const viewsDir = fastify.config.viewsDir;
const entryPoint = `${viewsDir}/index.ts`;
const { host, port } = await esbuild.serve(
{
host: "127.0.0.1",
servedir: viewsDir,
},
{
bundle: true,
minify: true,
entryPoints: [entryPoint],
define: {
"import.meta.env.QUOT_API_ENDPOINT": JSON.stringify(
fastify.config.apiEndpoint
),
},
}
);
await fastify.register(httpProxy, {
upstream: `http://${host}:${port}`,
async preHandler(req) {
if (!/\.(?:html|css|js)$/.test(req.url)) req.raw.url = "/";
},
});
}
export default views;

View file

@ -1,59 +1,3 @@
import type { AddressInfo } from "node:net";
import http from "node:http";
import fs from "node:fs";
import esbuild from "esbuild";
import { App, start } from "./controllers/index";
async function main() {
const port = Number(process.env.PORT ?? "8080");
const apiUrl = process.env.QUOT_API_URL || "http://127.0.0.1:3000";
const viewsDir = `${__dirname}/views`;
const htmlPath = `${viewsDir}/index.html`;
const scriptPath = `${viewsDir}/index.ts`;
const result = await esbuild.serve(
{
host: "127.0.0.1",
servedir: viewsDir,
},
{
bundle: true,
minify: true,
entryPoints: [scriptPath],
define: {
"import.meta.env.QUOT_API_URL": JSON.stringify(
process.env.QUOT_API_URL ?? ""
),
},
}
);
const handler: http.RequestListener = (req, res) => {
const api = req.url?.startsWith("/api/") && new URL(apiUrl);
const options = {
hostname: /**/ api ? api.hostname /* */ : result.host,
port: /* */ api ? api.port /* */ : result.port,
path: /* */ api ? req.url?.slice("/api".length) /**/ : req.url,
method: req.method,
headers: req.headers,
};
const proxyReq = http.request(options, (proxyRes) => {
if (api || proxyRes.statusCode === 200) {
res.writeHead(proxyRes.statusCode ?? 500, proxyRes.headers);
proxyRes.pipe(res);
} else {
res.writeHead(200, { "Content-Type": "text/html" });
fs.createReadStream(htmlPath).pipe(res);
}
});
req.pipe(proxyReq);
};
const server = http.createServer(handler).listen(port);
const address = server.address() as AddressInfo;
console.log(`http://0.0.0.0:${address.port}`);
}
main();
start(App());

1045
app/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
"private": true,
"scripts": {
"test": "tsc --noEmit",
"start": "node -r esbuild-register main.ts"
"start": "TS_NODE_DEV=true node -r esbuild-register main.ts"
},
"engines": {
"node": "^18.7.0"
@ -12,10 +12,14 @@
"packageManager": "npm@8.19.0",
"dependencies": {
"@exampledev/new.css": "^1.1.3",
"@fastify/http-proxy": "^8.2.2",
"@lexical/plain-text": "^0.3.11",
"esbuild": "^0.15.5",
"esbuild-register": "^3.3.3",
"fastify": "^4.5.3",
"jose": "^4.9.2",
"lexical": "^0.3.11",
"openid-client": "^5.1.9",
"solid-js": "^1.4.8"
},
"devDependencies": {

View file

@ -3,11 +3,32 @@ import { createSignal } from "solid-js";
import Index from "./pages/index";
import Page from "./pages/page";
import random from "./helpers/random";
import { decodeJwt } from "jose";
const routes = {
"/": Index,
};
async function updateUser(jwt: string): Promise<boolean> {
const decoded = decodeJwt(jwt);
const id = decoded.sub ?? "";
if (!id) return false;
const res = await fetch(
`${import.meta.env.QUOT_API_ENDPOINT}/users?id=eq.${encodeURIComponent(
id
)}`,
{
method: "PUT",
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
},
body: JSON.stringify({ id }),
}
);
return res.ok;
}
export default () => {
const [pathname, setPathname] = createSignal(
document.location.pathname as keyof typeof routes
@ -29,6 +50,18 @@ export default () => {
setPathname(document.location.pathname as keyof typeof routes);
});
if (window.location.hash) {
const params = new URLSearchParams(window.location.hash.slice(1));
const jwt = params.get("jwt");
if (jwt) {
window.history.replaceState({}, "", ".");
window.localStorage.setItem("jwt", jwt);
updateUser(jwt);
}
}
const authenticated = Boolean(window.localStorage.getItem("jwt"));
return () => (
<>
<header>
@ -36,7 +69,7 @@ export default () => {
<a href="/">Quot</a>
</h1>
<nav>
<a href="/new">📄</a>
<a href={authenticated ? "/new" : "/login"}>📄</a>
</nav>
</header>
{routes[pathname()] ?? (

2
app/views/env.d.ts vendored
View file

@ -1,5 +1,5 @@
interface ImportMeta {
env: {
QUOT_API_URL: string;
QUOT_API_ENDPOINT: string;
};
}

View file

@ -4,8 +4,10 @@ import Cards from "../components/cards";
import Card from "../components/card";
async function fetchPages(): Promise<Pages.Response> {
const jwt = window.localStorage.getItem("jwt");
const res = await fetch(
`${import.meta.env.QUOT_API_URL}/api/pages?order=updated.desc`
`${import.meta.env.QUOT_API_ENDPOINT}/pages?order=updated.desc`,
{ headers: jwt ? { authorization: `Bearer ${jwt}` } : {} }
);
const data = (await res.json()) as Pages.Response;
return data;

View file

@ -10,11 +10,16 @@ async function updatePage(
id: number,
content: Pages.RequestContentPage
): Promise<boolean> {
const jwt = window.localStorage.getItem("jwt");
if (!jwt) return false;
const res = await fetch(
`${import.meta.env.QUOT_API_URL}/api/pages?id=eq.${id}`,
`${import.meta.env.QUOT_API_ENDPOINT}/pages?id=eq.${id}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
headers: {
authorization: `Bearer ${jwt}`,
"content-type": "application/json",
},
body: JSON.stringify(content),
}
);
@ -22,16 +27,23 @@ async function updatePage(
}
async function deletePage(id: number): Promise<boolean> {
const jwt = window.localStorage.getItem("jwt");
if (!jwt) return false;
const res = await fetch(
`${import.meta.env.QUOT_API_URL}/api/pages?id=eq.${id}`,
{ method: "DELETE" }
`${import.meta.env.QUOT_API_ENDPOINT}/pages?id=eq.${id}`,
{
method: "DELETE",
headers: { authorization: `Bearer ${jwt}` },
}
);
return res.ok;
}
async function fetchPage(id: number): Promise<Pages.ResponsePage> {
const jwt = window.localStorage.getItem("jwt");
const res = await fetch(
`${import.meta.env.QUOT_API_URL}/api/pages?id=eq.${id}`
`${import.meta.env.QUOT_API_ENDPOINT}/pages?id=eq.${id}`,
{ headers: jwt ? { authorization: `Bearer ${jwt}` } : {} }
);
const data = (await res.json()) as Pages.Response;
return data[0]!;

View file

@ -5,10 +5,30 @@ services:
restart: unless-stopped
ports: ["8080:8080"]
environment:
# https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
QUOT_OPENID_ISSUER: ${QUOT_OPENID_ISSUER:?}
# https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1
QUOT_OPENID_CLIENT_ID: ${QUOT_OPENID_CLIENT_ID:?}
QUOT_OPENID_CLIENT_SECRET: ${QUOT_OPENID_CLIENT_SECRET:?}
QUOT_JWK: ${QUOT_JWK:?} # https://mkjwk.org
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@/postgres?host=/var/run/postgresql
volumes:
- postgres_socket:/var/run/postgresql
depends_on: [db]
api:
profiles: [dev]
image: kou029w/quot
build: "."
restart: unless-stopped
ports: ["3000:3000"]
environment:
QUOT_JWK: ${QUOT_JWK:?} # https://mkjwk.org
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@/postgres?host=/var/run/postgresql
volumes:
- postgres_socket:/var/run/postgresql
depends_on:
db:
condition: service_healthy
dbmate:
profiles: [dev]
image: amacneil/dbmate:1.15@sha256:8fb25de3fce073e39eb3f9411af0410d0e26cc6d120544a7510b964e218abc27

View file

@ -1,6 +1,8 @@
-- migrate:up
CREATE ROLE anon;
ALTER ROLE anon SET statement_timeout = '1s';
CREATE ROLE guest;
ALTER ROLE guest SET statement_timeout = '1s';
CREATE ROLE writer;
ALTER ROLE writer SET statement_timeout = '1s';
CREATE FUNCTION update_timestamp() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
@ -9,20 +11,52 @@ CREATE FUNCTION update_timestamp() RETURNS trigger LANGUAGE plpgsql AS $$
END
$$;
CREATE TABLE pages (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL UNIQUE,
text TEXT NOT NULL,
CREATE TABLE users (
id TEXT PRIMARY KEY DEFAULT current_setting('request.jwt.claims', true)::json ->> 'sub'::text,
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
GRANT ALL ON pages TO anon;
CREATE TRIGGER users_updated BEFORE UPDATE ON users FOR EACH ROW
EXECUTE PROCEDURE update_timestamp();
GRANT ALL (id) ON users TO writer;
GRANT SELECT, DELETE ON users TO writer;
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY users_policy ON users USING (
id = current_setting('request.jwt.claims', true)::json ->> 'sub'::text
);
CREATE TABLE pages (
id SERIAL PRIMARY KEY,
user_id TEXT REFERENCES users ON DELETE CASCADE DEFAULT current_setting('request.jwt.claims', true)::json ->> 'sub'::text,
title TEXT NOT NULL UNIQUE,
text TEXT NOT NULL,
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
published TIMESTAMPTZ
);
CREATE TRIGGER pages_updated BEFORE UPDATE ON pages FOR EACH ROW
EXECUTE PROCEDURE update_timestamp();
ALTER TABLE pages ENABLE ROW LEVEL SECURITY;
GRANT SELECT ON pages TO guest;
CREATE POLICY pages_guest_read_policy ON pages TO guest USING (
published <= CURRENT_TIMESTAMP
);
GRANT SELECT, DELETE ON pages TO writer;
GRANT ALL (id, title, text, published) ON pages TO writer;
GRANT ALL ON SEQUENCE pages_id_seq TO writer;
CREATE POLICY pages_write_policy ON pages TO writer USING (
user_id = current_setting('request.jwt.claims', true)::json ->> 'sub'::text
);
-- migrate:down
DROP TABLE users;
DROP TABLE pages;
DROP FUNCTION update_timestamp();
DROP ROLE anon;
DROP ROLE guest;
DROP ROLE writer;

View file

@ -33,10 +33,12 @@ SET default_table_access_method = heap;
CREATE TABLE public.pages (
id integer NOT NULL,
user_id text DEFAULT ((current_setting('request.jwt.claims'::text, true))::json ->> 'sub'::text),
title text NOT NULL,
text text NOT NULL,
created timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
updated timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
published timestamp with time zone
);
@ -69,6 +71,17 @@ CREATE TABLE public.schema_migrations (
);
--
-- Name: users; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.users (
id text DEFAULT ((current_setting('request.jwt.claims'::text, true))::json ->> 'sub'::text) NOT NULL,
created timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--
-- Name: pages id; Type: DEFAULT; Schema: public; Owner: -
--
@ -100,6 +113,14 @@ ALTER TABLE ONLY public.schema_migrations
ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
--
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
--
-- Name: pages pages_updated; Type: TRIGGER; Schema: public; Owner: -
--
@ -107,6 +128,54 @@ ALTER TABLE ONLY public.schema_migrations
CREATE TRIGGER pages_updated BEFORE UPDATE ON public.pages FOR EACH ROW EXECUTE FUNCTION public.update_timestamp();
--
-- Name: users users_updated; Type: TRIGGER; Schema: public; Owner: -
--
CREATE TRIGGER users_updated BEFORE UPDATE ON public.users FOR EACH ROW EXECUTE FUNCTION public.update_timestamp();
--
-- Name: pages pages_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.pages
ADD CONSTRAINT pages_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
--
-- Name: pages; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.pages ENABLE ROW LEVEL SECURITY;
--
-- Name: pages pages_guest_read_policy; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY pages_guest_read_policy ON public.pages TO guest USING ((published <= CURRENT_TIMESTAMP));
--
-- Name: pages pages_write_policy; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY pages_write_policy ON public.pages TO writer USING ((user_id = ((current_setting('request.jwt.claims'::text, true))::json ->> 'sub'::text)));
--
-- Name: users; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
--
-- Name: users users_policy; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY users_policy ON public.users USING ((id = ((current_setting('request.jwt.claims'::text, true))::json ->> 'sub'::text)));
--
-- PostgreSQL database dump complete
--

View file

@ -3,5 +3,6 @@ set -e
dbmate --wait up
export PGRST_DB_URI="${PGRST_DB_URI:-${DATABASE_URL}}"
export PGRST_DB_SCHEMA="${PGRST_DB_SCHEMA:-public}"
export PGRST_DB_ANON_ROLE="${PGRST_DB_ANON_ROLE:-anon}"
export PGRST_DB_ANON_ROLE="${PGRST_DB_ANON_ROLE:-guest}"
export PGRST_JWT_SECRET="${QUOT_JWK}"
exec "$@"