giga refactor of the API calls

This commit is contained in:
Hamcha 2023-07-29 16:00:57 +02:00
parent 526763b795
commit b6d9d45398
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
29 changed files with 743 additions and 933 deletions

1099
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,9 @@
"name": "dipper",
"version": "0.0.1",
"private": true,
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
@ -16,17 +19,17 @@
"@sveltejs/kit": "^1.20.4",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"eslint": "^8.28.0",
"eslint": "^8.45.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"prettier": "^2.8.0",
"prettier": "^2.8.8",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.0",
"svelte-check": "^3.4.3",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^4.3.6",
"sveltekit-adapter-deno": "^0.10.1"
"typescript": "^5.1.6",
"vite": "^4.4.0",
"sveltekit-adapter-deno": "^0.10.2"
},
"type": "module",
"dependencies": {}

44
src/lib/api/auth.ts Normal file
View file

@ -0,0 +1,44 @@
import type { Cookies } from '@sveltejs/kit';
import { callJSON, callText } from './request';
interface LoginParameters {
username: string;
password: string;
}
interface LoginResponse {
expires_at: string;
session_id: string;
}
interface OkResponse {
ok: true;
}
export const login = (cookies: Cookies, username: string, password: string) =>
callJSON<LoginResponse, LoginParameters>(
'POST',
'auth/login',
{
username,
password
},
cookies
);
export const logout = (cookies: Cookies) =>
callJSON<OkResponse>('POST', 'auth/logout', undefined, cookies);
export const getLoggedInUser = (cookies: Cookies) => callText('GET', 'auth/me', undefined, cookies);
export async function isLoggedIn(cookies: Cookies) {
const session = cookies.get('session');
if (session) {
try {
// Check that login is valid
await getLoggedInUser(cookies);
return true;
} catch (e) {}
}
return false;
}

View file

@ -0,0 +1,21 @@
import type { Cookies } from '@sveltejs/kit';
import type { PaginatedWithCursor, TimePaginationQuery } from './pagination';
import type { Post } from './posts';
import { callJSON, withQuery } from './request';
export interface Collection {
id: string;
name: string;
slug: string;
parent: string | null;
}
export const getCollectionPosts = (
name: string,
collection: string,
pagination?: TimePaginationQuery
) =>
callJSON<PaginatedWithCursor<Post, string>>(
'GET',
withQuery(`collections/${name}/${collection}/posts`, pagination)
);

82
src/lib/api/cookies.ts Normal file
View file

@ -0,0 +1,82 @@
import { PUBLIC_COOKIES_SECURE, PUBLIC_DOMAIN } from '$env/static/public';
import { parseBool } from '$lib/env';
import type { Cookies } from '@sveltejs/kit';
interface CookieInfo {
name: string;
value: string;
options: {
SameSite?: 'Strict' | 'Lax' | 'None';
Secure: boolean;
Partitioned: boolean;
HttpOnly: boolean;
Expires?: Date;
'Max-Age'?: number;
Path?: string;
Domain?: string;
};
}
function parseCookieHeader(headerValue: string): CookieInfo {
const [nv, ...options] = headerValue.split(';');
const [name, value] = nv.split('=').map((v) => v.trim());
const cookie: CookieInfo = {
name,
value,
options: {
Secure: false,
Partitioned: false,
HttpOnly: false
}
};
options.forEach((option) => {
const [key, value] = option.split('=').map((v) => v.trim());
switch (key) {
case 'Secure':
cookie.options.Secure = true;
break;
case 'SameSite':
cookie.options.SameSite = value as 'Strict' | 'Lax' | 'None';
break;
case 'Partitioned':
cookie.options.Partitioned = true;
break;
case 'HttpOnly':
cookie.options.HttpOnly = true;
break;
case 'Expires':
cookie.options.Expires = new Date(value);
break;
case 'Max-Age':
cookie.options['Max-Age'] = parseInt(value, 10);
break;
case 'Path':
cookie.options.Path = value;
break;
case 'Domain':
cookie.options.Domain = value;
break;
}
});
return cookie;
}
export function passthroughSession(response: Response, cookies: Cookies) {
if (response.ok) {
const cookieHeader = response.headers.get('Set-Cookie');
if (!cookieHeader) {
return;
}
const info = parseCookieHeader(cookieHeader);
cookies.set(info.name, info.value, {
httpOnly: info.options.HttpOnly,
secure: parseBool(PUBLIC_COOKIES_SECURE),
sameSite: 'strict',
domain: PUBLIC_DOMAIN,
expires: info.options.Expires,
maxAge: info.options['Max-Age']
});
}
}

11
src/lib/api/pagination.ts Normal file
View file

@ -0,0 +1,11 @@
import type { QuerySerializable } from './request';
export interface PaginatedWithCursor<T, C> {
items: T[];
next_cursor: C | null;
}
export interface TimePaginationQuery extends QuerySerializable {
limit: number;
before: Date | null;
}

19
src/lib/api/posts.ts Normal file
View file

@ -0,0 +1,19 @@
import { callJSON } from './request';
export interface Post {
id: string;
site: string;
author: string;
author_display_name: string;
slug: string;
title: string;
description: string | null;
tags: string[];
blocks: [];
created_at: string;
modified_at: string | null;
deleted_at: string | null;
}
export const getPost = async (site: string, slug: string) =>
callJSON<Post>('GET', `posts/${site}/${slug}`);

91
src/lib/api/request.ts Normal file
View file

@ -0,0 +1,91 @@
import { PUBLIC_API_BASE } from '$env/static/public';
import type { Cookies } from '@sveltejs/kit';
import { passthroughSession } from './cookies';
export class APIError extends Error {
readonly code?: string;
constructor(readonly response: Response, body?: { code: string; message: string }) {
super(body?.message ?? response.statusText);
this.code = body?.code;
Object.setPrototypeOf(this, APIError.prototype);
}
}
export type Stringer = { toString(): string };
export type QuerySerializable = { [key: string | number | symbol]: Stringer | null | undefined };
export function withQuery(path: string, query?: QuerySerializable) {
return query ? `${path}?${asQueryParams(query).toString()}` : path;
}
export function asQueryParams<T extends QuerySerializable>(obj: T) {
const params = new URLSearchParams();
for (const key of Object.getOwnPropertyNames(obj)) {
const value = obj[key];
if (value === null || value === undefined || typeof value === 'undefined') continue;
params.append(key, value.toString());
}
return params;
}
type Body = { [key: string]: unknown } | object | void;
export async function apiCall<B extends Body>(
method: string,
path: string,
body?: B,
cookies?: Cookies
) {
const session = cookies?.get('session');
const response = await fetch(`${PUBLIC_API_BASE}/${path}`, {
method,
body: body ? JSON.stringify(body) : undefined,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Cookie: session ? `session=${session}` : ''
}
});
if (cookies) {
passthroughSession(response, cookies);
}
if (response.ok) {
return response;
}
// Check for content
if (response.headers.get('Content-Type')?.includes('application/json')) {
const body = await response.json();
throw new APIError(response, body);
}
throw new APIError(response);
}
export async function callJSON<R, B extends Body = void>(
method: string,
path: string,
body?: B,
cookies?: Cookies
) {
return apiCall(method, path, body, cookies).then((response) => response.json() as R);
}
export async function callText<B extends Body = void>(
method: string,
path: string,
body?: B,
cookies?: Cookies
) {
return apiCall(method, path, body, cookies).then((response) => response.text());
}
export async function callBytes<B extends Body = void>(
method: string,
path: string,
body?: B,
cookies?: Cookies
) {
return apiCall(method, path, body, cookies).then((response) => response.blob());
}

17
src/lib/api/sites.ts Normal file
View file

@ -0,0 +1,17 @@
import type { Collection } from './collections';
import { callJSON } from './request';
export interface Site {
name: string;
owner: string;
owner_display_name: string;
title: string;
description: string | null;
collections: Collection[];
default_collection: string;
created_at: string;
modified_at: string | null;
deleted_at: string | null;
}
export const getSiteInfo = (name: string) => callJSON<Site>('GET', `sites/${name}`);

16
src/lib/env.ts Normal file
View file

@ -0,0 +1,16 @@
export function parseBool(str: string, defaultValue: boolean = false): boolean {
switch (str.toLowerCase().trim()) {
case 'true':
case 'yes':
case 'on':
case '1':
return true;
case 'false':
case 'no':
case 'off':
case '0':
return false;
default:
return defaultValue;
}
}

26
src/lib/errors.ts Normal file
View file

@ -0,0 +1,26 @@
import { error } from '@sveltejs/kit';
import { APIError } from './api/request';
/// Maps an error from the backend to a SvelteKit error.
export function mapBackendError(e: unknown) {
if (e instanceof APIError) {
switch (e.response.status) {
case 404:
return error(404, 'Not Found');
default:
// TODO handle better
return error(500, e.response.statusText);
}
}
return error(502, 'Platform is down');
}
/// Ensures that a promise resolves to a value, or throws the appropriate error otherwise.
export async function must<T>(promise: Promise<T>): Promise<T> {
try {
return await promise;
} catch (err) {
throw mapBackendError(err);
}
}

View file

@ -1,39 +0,0 @@
export interface Site {
name: string;
owner: string;
owner_display_name: string;
title: string;
description: string | null;
collections: Collection[];
default_collection: string;
created_at: string;
modified_at: string | null;
deleted_at: string | null;
}
export interface Collection {
id: string;
name: string;
slug: string;
parent: string | null;
}
export interface Post {
id: string;
site: string;
author: string;
author_display_name: string;
slug: string;
title: string;
description: string | null;
tags: string[];
blocks: [];
created_at: string;
modified_at: string | null;
deleted_at: string | null;
}
export interface PaginatedWithCursor<T, C> {
items: T[];
next_cursor: C | null;
}

View file

@ -3,3 +3,10 @@ import { PUBLIC_DOMAIN } from '$env/static/public';
export function getSiteName(origin: string) {
return origin.substring(0, origin.indexOf(PUBLIC_DOMAIN)).replaceAll('.', '');
}
export function getLoginURL(base: URL) {
const copy = new URL(base);
copy.hostname = PUBLIC_DOMAIN;
copy.pathname = '/login';
return copy.toString();
}

View file

@ -0,0 +1,3 @@
<h2>cenere!!!</h2>
<slot />

View file

@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { isLoggedIn } from '$lib/api/auth';
export const load = (async ({ cookies }) => {
if (!(await isLoggedIn(cookies))) {
throw redirect(302, '/login');
}
}) satisfies LayoutServerLoad;

View file

@ -0,0 +1,5 @@
<slot />
<form method="POST" action="/login?/logout">
<button type="submit">logout</button>
</form>

View file

@ -0,0 +1 @@
aaaa

View file

@ -0,0 +1,47 @@
import { PUBLIC_API_BASE } from '$env/static/public';
import { redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { APIError } from '$lib/api/request';
import { getLoggedInUser, login, logout } from '$lib/api/auth';
export const load = async function ({ url, cookies }) {
const session = cookies.get('session');
// Check that login is valid
if (session) {
let user = null;
try {
user = await getLoggedInUser(cookies);
} catch (e) {}
if (user) {
throw redirect(302, url.searchParams.get('then') || '/admin');
}
}
} satisfies PageServerLoad;
export const actions = {
login: async ({ cookies, request, url }) => {
const data = await request.formData();
const username = data.get('username')?.toString();
const password = data.get('password')?.toString();
if (!username || !password) return { error: 'missing credentials' };
try {
await login(cookies, username, password);
throw redirect(302, url.searchParams.get('then') || '/admin');
} catch (e) {
if (e instanceof APIError) {
switch (e.code) {
case 'invalid-login':
return { error: 'invalid credentials' };
}
}
return { error: 'unknown internal error' };
}
},
logout: async ({ url, cookies }) => {
await logout(cookies);
throw redirect(302, url.searchParams.get('then') || '/login');
}
} satisfies Actions;

View file

@ -0,0 +1,15 @@
<script lang="ts">
import type { ActionData } from './$types';
export let form: ActionData;
</script>
{#if form}
OH NOES: {form.error}
{/if}
<form method="POST" action="/login?/login">
<input name="username" placeholder="user id" />
<input name="password" type="password" placeholder="password" />
<button type="submit">sign in</button>
</form>

View file

@ -0,0 +1,11 @@
<script lang="ts">
import type { LayoutData } from './$types';
export let data: LayoutData;
</script>
<header><h2><a href="/">{data.site.title}</a></h2></header>
<slot />
<footer>
<a href={data.links.login}>login</a>
</footer>

View file

@ -0,0 +1,22 @@
import { PUBLIC_WEBSITE_URL } from '$env/static/public';
import { getLoginURL, getSiteName } from '$lib/url';
import { redirect } from '@sveltejs/kit';
import type { LayoutLoad } from './$types';
import { getSiteInfo } from '$lib/api/sites';
import { must } from '$lib/errors';
export const load = (async ({ url }) => {
const site = getSiteName(url.hostname);
if (!site) {
throw redirect(302, PUBLIC_WEBSITE_URL);
}
const siteData = await must(getSiteInfo(site));
return {
site: siteData,
links: {
login: getLoginURL(url)
}
};
}) satisfies LayoutLoad;

View file

@ -8,7 +8,7 @@
{#each data.pages.items as post}
<article>
<header>
<h2>{post.title}</h2>
<h2><a href="/p/{post.slug}">{post.title}</a></h2>
<h4>by {post.author_display_name} on {new Date(post.created_at).toLocaleDateString()}</h4>
</header>
</article>

View file

@ -0,0 +1,11 @@
import { getCollectionPosts } from '$lib/api/collections';
import { must } from '$lib/errors';
import type { PageLoad } from './$types';
export const load = (async ({ parent }) => {
const { site } = await parent();
return {
pages: await must(getCollectionPosts(site.name, site.default_collection))
};
}) satisfies PageLoad;

View file

@ -0,0 +1,11 @@
import type { PageLoad } from './$types';
import { getPost } from '$lib/api/posts';
import { must } from '$lib/errors';
export const load = (async ({ params, parent }) => {
const { site } = await parent();
return {
page: await must(getPost(site.name, params.slug))
};
}) satisfies PageLoad;

View file

@ -1,8 +0,0 @@
<script lang="ts">
import type { LayoutData } from './$types';
export let data: LayoutData;
</script>
<header><h2>{data.site.title}</h2></header>
<slot />

View file

@ -1,17 +0,0 @@
import { PUBLIC_API_BASE, PUBLIC_WEBSITE_URL } from '$env/static/public';
import type { Site } from '$lib/mabel-types';
import { getSiteName } from '$lib/url';
import { error, redirect } from '@sveltejs/kit';
import type { LayoutLoad } from './$types';
export const load = (async ({ url }) => {
const site = getSiteName(url.hostname);
if (!site) throw redirect(301, PUBLIC_WEBSITE_URL);
const siteData = await fetch(`${PUBLIC_API_BASE}/sites/${site}`);
if (siteData.status === 404) throw error(404, 'Not Found');
return {
site: (await siteData.json()) as Site
};
}) satisfies LayoutLoad;

View file

@ -1,15 +0,0 @@
import { PUBLIC_API_BASE } from '$env/static/public';
import type { PaginatedWithCursor, Post } from '$lib/mabel-types';
import type { PageLoad } from './$types';
export const load = (async ({ parent }) => {
const { site } = await parent();
const data = await fetch(
`${PUBLIC_API_BASE}/collections/${site.name}/${site.default_collection}/posts`
);
return {
pages: (await data.json()) as PaginatedWithCursor<Post, string>
};
}) satisfies PageLoad;

View file

@ -1,14 +0,0 @@
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { PUBLIC_API_BASE } from '$env/static/public';
import type { Post } from '$lib/mabel-types';
export const load = (async ({ params, parent }) => {
const { site } = await parent();
const pageData = await fetch(`${PUBLIC_API_BASE}/posts/${site.name}/${params.slug}`);
if (pageData.status === 404) throw error(404, 'Not Found');
return {
page: (await pageData.json()) as Post
};
}) satisfies PageLoad;