diff --git a/package-lock.json b/package-lock.json index a1ddde3..ba3e446 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "sequelize": "^6.37.1", "sqlite3": "^5.1.7", "svelte-countup": "^0.2.6", + "svelte-markdown": "^0.4.1", "typeorm": "^0.3.20", "uuid": "^9.0.1", "verify-hcaptcha": "^1.0.0", @@ -1592,6 +1593,11 @@ "@types/node": "*" } }, + "node_modules/@types/marked": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", + "integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==" + }, "node_modules/@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", @@ -8061,6 +8067,17 @@ "node": ">=0.10.0" } }, + "node_modules/marked": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-5.1.2.tgz", + "integrity": "sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/matchdep": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/matchdep/-/matchdep-2.0.0.tgz", @@ -11918,6 +11935,18 @@ "svelte": "^3.19.0 || ^4.0.0" } }, + "node_modules/svelte-markdown": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/svelte-markdown/-/svelte-markdown-0.4.1.tgz", + "integrity": "sha512-pOlLY6EruKJaWI9my/2bKX8PdTeP5CM0s4VMmwmC2prlOkjAf+AOmTM4wW/l19Y6WZ87YmP8+ZCJCCwBChWjYw==", + "dependencies": { + "@types/marked": "^5.0.1", + "marked": "^5.1.2" + }, + "peerDependencies": { + "svelte": "^4.0.0" + } + }, "node_modules/svelte-parse-markup": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/svelte-parse-markup/-/svelte-parse-markup-0.1.2.tgz", diff --git a/package.json b/package.json index 55c75f5..ee903cb 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "sequelize": "^6.37.1", "sqlite3": "^5.1.7", "svelte-countup": "^0.2.6", + "svelte-markdown": "^0.4.1", "typeorm": "^0.3.20", "uuid": "^9.0.1", "verify-hcaptcha": "^1.0.0", diff --git a/src/lib/util/animation/index.ts b/src/lib/util/animation/index.ts new file mode 100644 index 0000000..5487394 --- /dev/null +++ b/src/lib/util/animation/index.ts @@ -0,0 +1,24 @@ +interface Animation { + duration: number; + delay: number; + property: string | string[]; + timingFunction: string; +} + +export const transition = + "linear(0,0.006,0.025 2.8%,0.101 6.1%,0.539 18.9%,0.721 25.3%,0.849 31.5%,0.937 38.1%,0.968 41.8%,0.991 45.7%,1.006 50.1%,1.015 55%,1.017 63.9%,1.001)"; + +export function generateTransition(animations: Animation[]) { + return animations + .map((animation) => + Array.isArray(animation.property) + ? animation.property + .map( + (property) => + `${property} ${animation.duration}ms ${animation.timingFunction} ${animation.delay * 50}ms`, + ) + .join(", ") + : `${animation.property} ${animation.duration}ms ${animation.timingFunction} ${animation.delay * 50}ms`, + ) + .join(", "); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index fd66ae2..5a6ed4b 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -9,6 +9,7 @@ import type { TransitionConfig } from "svelte/transition"; import type { PageData } from "./$types"; import { bounceOut } from "svelte/easing"; + import { generateTransition, transition } from "$lib/util/animation"; export let data: PageData; @@ -18,31 +19,7 @@ href: string; } - interface Animation { - duration: number; - delay: number; - property: string | string[]; - timingFunction: string; - } - - function generateTransition(animations: Animation[]) { - return animations - .map((animation) => - Array.isArray(animation.property) - ? animation.property - .map( - (property) => - `${property} ${animation.duration}ms ${animation.timingFunction} ${animation.delay * 50}ms`, - ) - .join(", ") - : `${animation.property} ${animation.duration}ms ${animation.timingFunction} ${animation.delay * 50}ms`, - ) - .join(", "); - } - const token = writable(""); - const transition = - "linear(0,0.006,0.025 2.8%,0.101 6.1%,0.539 18.9%,0.721 25.3%,0.849 31.5%,0.937 38.1%,0.968 41.8%,0.991 45.7%,1.006 50.1%,1.015 55%,1.017 63.9%,1.001)"; function transitionIn(node: HTMLElement, { duration = 360 }: TransitionConfig) { const UA = navigator.userAgent; @@ -117,7 +94,7 @@ ], { easing: transition, - duration, + duration: duration, }, ); return { @@ -139,7 +116,7 @@ const navItems: NavItem[] = [ { name: "Blog", - href: "/coming-soon", + href: "/blog", }, { name: "Docs", @@ -251,7 +228,7 @@
- Blog + Blog Docs FAQ
diff --git a/src/routes/blog/+page.server.ts b/src/routes/blog/+page.server.ts new file mode 100644 index 0000000..38f0748 --- /dev/null +++ b/src/routes/blog/+page.server.ts @@ -0,0 +1,37 @@ +import path from "path"; +import fs from "fs/promises"; +import { error } from "@sveltejs/kit"; + +export async function load({ params }) { + const basePath = "static/blog"; + const files = await fs.readdir(basePath); + // get all file contents in an array + const posts = await Promise.all( + files.map(async (filename) => { + const filePath = path.join(basePath, filename); + let contents = await fs.readFile(filePath, "utf-8"); + const title = + contents + .split("\n") + .find((line) => line.startsWith("#")) + ?.slice(1) || + filename + .split("-") + .slice(1) + .join(" ") + .split(".md") + .slice(0, -1) + .join(".md") + .replace(/^\w/gm, (c) => c.toUpperCase()); + // remove title from contents + return { + contents: contents.split("\n").slice(1).join("\n"), + title, + slug: filename.split(".md").slice(0, -1).join(".md").split("-").slice(1).join("-"), + }; + }), + ); + return { + posts, + }; +} diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte new file mode 100644 index 0000000..ecd8152 --- /dev/null +++ b/src/routes/blog/+page.svelte @@ -0,0 +1,121 @@ + + +

Blog Posts

+
+ {#each data.posts as post} + +
+
+

+ {post.title} +

+

+ +

+
+
+
+ {/each} +
+ + diff --git a/src/routes/blog/[page]/+page.server.ts b/src/routes/blog/[page]/+page.server.ts new file mode 100644 index 0000000..3a8c084 --- /dev/null +++ b/src/routes/blog/[page]/+page.server.ts @@ -0,0 +1,29 @@ +import path from "path"; +import fs from "fs/promises"; +import { error } from "@sveltejs/kit"; + +export async function load({ params }) { + const basePath = "static/blog"; + const page = params.page; + // const contents = await fs.readFile(filePath, "utf-8"); + const files = await fs.readdir(basePath); + const searchableFiles = files.map((f) => + f.split("-").slice(1).join("-").split(".md").slice(0, -1).join(".md"), + ); + + const index = searchableFiles.indexOf(page); + const filename = files.at(index); + if (!filename || index === -1) { + error(404, "Not found"); + } + + const filePath = path.join(basePath, filename); + const contents = await fs.readFile(filePath, "utf-8"); + + return { + props: { + contents, + author: filename.split("-")[0], + }, + }; +} diff --git a/src/routes/blog/[page]/+page.svelte b/src/routes/blog/[page]/+page.svelte new file mode 100644 index 0000000..8debb32 --- /dev/null +++ b/src/routes/blog/[page]/+page.svelte @@ -0,0 +1,84 @@ + + +

{data.props.author} presents...

+
+ +
+ + diff --git a/static/blog/nullptr-getting-started-with-ldn.md b/static/blog/nullptr-getting-started-with-ldn.md new file mode 100644 index 0000000..499c2b7 --- /dev/null +++ b/static/blog/nullptr-getting-started-with-ldn.md @@ -0,0 +1,60 @@ +# Getting Started with Suyu's LDN (Local Wireless) + +Before Suyu, there was Yuzu. And the Yuzu developers, one day, chose to implement LDN (Local Wireless Multiplayer) which emulated Nintendo's local wireless stack. This post won't go into the details of Nintendo's stack, since this is a post to document the undocumented: Yuzu's closed-source lobby-listing and authentication API. + +## Initial Authentication + +The lobby server operates using key-like authentication. You sign up on some external site, which then provides you with a key, base64 encoded in the following format:

+`username:[random hex data]`

+The difficulty of generating this key is that:
+ +- The longer your username is, the less secure it becomes +- The base64 is limited to 80 characters. Calculating that length is hard. +- Generally parsing it is a nuisance (what if the username contains a colon?)

+ +Once you've worked around that, you generate a key. In Suyu/Yuzu, go into the settings and attempt to verify it. This may first send a request to `/jwt/external/key.pem` in order to retrieve the JWT public key. The server will send a valid key in .pem format.

+ +Next, it'll send a POST request to `/jwt/internal`, with the username and token in `x-username` and `x-token` headers respectively. The server will verify the username and token combination, then sign a JWT for you using RS256 and send it back. This JWT is then verified against the pubkey, then used for all future requests.

+ +Finally, it GETs /profile, which will decode your JWT, find your user and return your username (`{ username: string }`). This is to verify that the JWT is valid and that the user exists.

+ +## Lobby Listing + +Here are all the types for lobbies ("rooms") used in the suyu codebase:

+ +```ts +export interface IRoom { + address: string; + description: string; + externalGuid: string; + hasPassword: boolean; + id: string; + maxPlayers: number; + name: string; + netVersion: number; + owner: string; + players: RoomPlayer[]; + port: number; + preferredGameId: number; + preferredGameName: string; +} + +export interface RoomPlayer { + gameId: number; + gameName: string; + nickname: string; +} +``` + +

+`GET /lobby` takes no parameters (other than an auth header, Bearer {token} afaik) and returns a list of rooms, `{ rooms: IRoom[] }`.

+ +`POST /lobby` takes a JSON body of type `IRoom` (ish) and returns the room info. This is used to create a room.

+ +`POST /lobby/{id}` takes a JSON body of type `{ players: RoomPlayer[] }` and returns `{ message: "Lobby successfully updated." }`. This is used to update the room's player list, and should only be able to be called by the room's owner.

+ +`DELETE /lobby/{id}` takes no parameters and returns the room's info as an `IRoom`. This is used to delete a room, and should also only be able to be called by the room's owner.

+ +## Conclusion + +This is a very basic overview of the LDN API. It's not very complex, but it's also not very well documented. Hopefully this post will help you understand how to use it. If you have any questions, tag me in the Discord (`notnullptr`) or check the source code for more information. I hope this post was helpful to you. Good luck!