magic login basics

This commit is contained in:
emerald 2024-01-16 13:39:01 -05:00
parent 94edec4954
commit a0835c9de4
Signed by: emerald
GPG Key ID: 13F7EFB915A0F623
52 changed files with 8716 additions and 0 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
DATABASE_URL=postgres://postgres:password@localhost

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
Config.toml
pg_data

2
.tool-versions Normal file
View File

@ -0,0 +1,2 @@
yarn 1.22.19
nodejs 20.7.0

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"editor.formatOnSave": true,
"rust-analyzer.showUnlinkedFileNotification": false
}

5284
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

41
Cargo.toml Normal file
View File

@ -0,0 +1,41 @@
[package]
name = "omegafm"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1"
async-trait = "0.1.77"
axum = "0.7"
celery = "0.5.5"
figment = { version = "0.10.13", features = [
"env",
"toml",
"yaml",
] }
josekit = "0.8.5"
lazy_static = "1.4.0"
rand = "0.8.5"
serde = { version = "1.0.195", features = [
"derive",
] }
sqlx = { version = "0.7.3", features = [
"postgres",
"runtime-tokio",
"uuid",
] }
surrealdb = "1.1.0"
thiserror = "1.0.56"
tokio = { version = "1.35.1", features = [
"full",
] }
tower = "0.4.13"
tower-http = { version = "0.5", features = [
"fs",
] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = [
"env-filter",
] }

13
compose.yml Normal file
View File

@ -0,0 +1,13 @@
services:
postgres:
image: postgres:15
ports:
- 5432:5432
environment:
- POSTGRES_PASSWORD=password
volumes:
- ./pg_data:/var/lib/postgresql/data:z
# rabbit:
# image: rabbitmq:3
# ports:
# - 5672:5672

13
frontend/.eslintignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

30
frontend/.eslintrc.cjs Normal file
View File

@ -0,0 +1,30 @@
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

10
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

13
frontend/.prettierignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

9
frontend/.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

100
frontend/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,100 @@
{
"prettier.documentSelectors": [
"**/*.svelte"
],
"tailwindCSS.classAttributes": [
"class",
"accent",
"active",
"aspectRatio",
"background",
"bgBackdrop",
"bgDark",
"bgDrawer",
"bgLight",
"blur",
"border",
"button",
"buttonClasses",
"buttonTextFirst",
"buttonTextLast",
"buttonTextNext",
"buttonTextPrevious",
"caretClosed",
"caretOpen",
"color",
"controlSeparator",
"controlVariant",
"cursor",
"display",
"element",
"fill",
"fillDark",
"fillLight",
"flex",
"gap",
"gridColumns",
"height",
"hover",
"inactive",
"indent",
"justify",
"meter",
"padding",
"regionAnchor",
"regionBackdrop",
"regionBody",
"regionCaption",
"regionCaret",
"regionCell",
"regionChildren",
"regionCone",
"regionContent",
"regionControl",
"regionDefault",
"regionDrawer",
"regionFoot",
"regionFootCell",
"regionHead",
"regionHeadCell",
"regionHeader",
"regionIcon",
"regionInterface",
"regionInterfaceText",
"regionLabel",
"regionLead",
"regionLegend",
"regionList",
"regionListItem",
"regionNavigation",
"regionPage",
"regionPanel",
"regionRowHeadline",
"regionRowMain",
"regionSummary",
"regionSymbol",
"regionTab",
"regionTrail",
"ring",
"rounded",
"select",
"shadow",
"slotDefault",
"slotFooter",
"slotHeader",
"slotLead",
"slotMessage",
"slotMeta",
"slotPageContent",
"slotPageFooter",
"slotPageHeader",
"slotSidebarLeft",
"slotSidebarRight",
"slotTrail",
"spacing",
"text",
"track",
"width",
"zIndex"
]
}

38
frontend/README.md Normal file
View File

@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

2
frontend/justfile Normal file
View File

@ -0,0 +1,2 @@
build:
yarn build

46
frontend/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "playwright test",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@playwright/test": "^1.28.1",
"@skeletonlabs/skeleton": "2.1.0",
"@skeletonlabs/tw-plugin": "0.2.0",
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/kit": "^1.20.4",
"@tailwindcss/forms": "0.5.6",
"@tailwindcss/typography": "0.5.10",
"@types/node": "20.7.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"autoprefixer": "10.4.16",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"postcss": "8.4.30",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.5",
"svelte-check": "^3.4.3",
"tailwindcss": "3.3.3",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^4.4.2",
"vite-plugin-tailwind-purgecss": "0.1.3"
},
"type": "module",
"dependencies": {
"@floating-ui/dom": "1.5.3"
}
}

View File

@ -0,0 +1,12 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'tests',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
};
export default config;

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

9
frontend/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
// interface Locals {}
// interface PageData {}
// interface Error {}
// interface Platform {}
}

12
frontend/src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" data-theme="vintage">
<div style="display: contents" class="h-full overflow-hidden">%sveltekit.body%</div>
</body>
</html>

16
frontend/src/app.postcss Normal file
View File

@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind variants;
html,
body {
@apply h-full overflow-hidden;
}
/* vintage theme */
@font-face {
font-family: 'Abril Fatface';
src: url('/fonts/AbrilFatface.ttf');
font-display: swap;
}

View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@ -0,0 +1,49 @@
<script lang="ts">
import '../app.postcss';
import { AppShell, AppBar } from '@skeletonlabs/skeleton';
// Floating UI for Popups
import { computePosition, autoUpdate, flip, shift, offset, arrow } from '@floating-ui/dom';
import { storePopup } from '@skeletonlabs/skeleton';
storePopup.set({ computePosition, autoUpdate, flip, shift, offset, arrow });
</script>
<!-- App Shell -->
<AppShell>
<svelte:fragment slot="header">
<!-- App Bar -->
<AppBar>
<svelte:fragment slot="lead">
<strong class="text-xl uppercase">Skeleton</strong>
</svelte:fragment>
<svelte:fragment slot="trail">
<a
class="btn btn-sm variant-ghost-surface"
href="https://discord.gg/EXqV7W8MtY"
target="_blank"
rel="noreferrer"
>
Discord
</a>
<a
class="btn btn-sm variant-ghost-surface"
href="https://twitter.com/SkeletonUI"
target="_blank"
rel="noreferrer"
>
Twitter
</a>
<a
class="btn btn-sm variant-ghost-surface"
href="https://github.com/skeletonlabs/skeleton"
target="_blank"
rel="noreferrer"
>
GitHub
</a>
</svelte:fragment>
</AppBar>
</svelte:fragment>
<!-- Page Route Content -->
<slot />
</AppShell>

View File

@ -0,0 +1 @@
export const prerender = true;

View File

@ -0,0 +1,71 @@
<!-- YOU CAN DELETE EVERYTHING IN THIS PAGE -->
<div class="container h-full mx-auto flex justify-center items-center">
<div class="space-y-10 text-center flex flex-col items-center">
<h2 class="h2">Welcome to Skeleton.</h2>
<!-- Animated Logo -->
<figure>
<section class="img-bg" />
<svg
class="fill-token -scale-x-[100%]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 200 200"
>
<path
fill-rule="evenodd"
d="M98.77 50.95c25.1 0 46.54 8.7 61.86 23a41.34 41.34 0 0 0 5.19-1.93c4.35-2.02 10.06-6.17 17.13-12.43-1.15 10.91-2.38 18.93-3.7 24.04-.7 2.75-1.8 6.08-3.3 10a80.04 80.04 0 0 1 8.42 23.33c6.04 30.3-4.3 43.7-28.33 51.18.18.9.32 1.87.42 2.9.86 8.87-3.62 23.19-9 23.19-3.54 0-5.84-4.93-8.3-12.13-.78 8.34-4.58 17.9-8.98 17.9-4.73 0-7.25-8.84-10.93-20.13a214 214 0 0 1-.64 2.93l-.16.71-.16.71-.17.71c-1.84 7.58-4.46 15.07-8.5 15.07-5.06 0-2.29-15.9-10.8-22.63-43.14 2.36-79.43-13.6-79.43-59.62 0-8.48 2-16.76 5.69-24.45a93.72 93.72 0 0 1-1.77-3.68c-2.87-6.32-6.3-15.88-10.31-28.7 10.26 7.66 18.12 12.22 23.6 13.68.5.14 1.02.26 1.57.36 14.36-14.44 35.88-24.01 60.6-24.01Zm-9.99 62.3c-14.57 0-26.39 11.45-26.39 25.58 0 14.14 11.82 25.6 26.39 25.6s26.39-11.46 26.39-25.6c0-13.99-11.58-25.35-25.95-25.58Zm37.45 31.95c-4.4 0-6.73 9.4-6.73 13.62 0 3.3 1.1 5.12 2.9 5.45 4.39.4 3.05-5.97 5.23-5.97 1.06 0 2.2 1.35 3.34 2.73l.34.42c1.25 1.52 2.5 2.93 3.64 2.49 2.7-1.61 1.67-5.12.74-7.88-3.3-6.96-5.05-10.86-9.46-10.86Zm-36.85-28.45c12.57 0 22.76 9.78 22.76 21.85 0 12.07-10.2 21.85-22.76 21.85-.77 0-1.53-.04-2.29-.11 11.5-1.1 20.46-10.42 20.46-21.74 0-11.32-8.97-20.63-20.46-21.74.76-.07 1.52-.1 2.3-.1Zm65.54-5c-10.04 0-18.18 10.06-18.18 22.47 0 12.4 8.14 22.47 18.18 22.47s18.18-10.06 18.18-22.47c0-12.41-8.14-22.48-18.18-22.48Zm.6 3.62c8.38 0 15.16 8.4 15.16 18.74 0 10.35-6.78 18.74-15.16 18.74-.77 0-1.54-.07-2.28-.21 7.3-1.36 12.89-9.14 12.89-18.53 0-9.4-5.6-17.17-12.89-18.53.74-.14 1.5-.2 2.28-.2Zm3.34-72.27.12.07c.58.38.75 1.16.37 1.74l-2.99 4.6c-.35.55-1.05.73-1.61.44l-.12-.07a1.26 1.26 0 0 1-.37-1.74l2.98-4.6a1.26 1.26 0 0 1 1.62-.44ZM39.66 42l.08.1 2.76 3.93a1.26 1.26 0 0 1-2.06 1.45l-2.76-3.94A1.26 1.26 0 0 1 39.66 42Zm63.28-42 2.85 24.13 10.62-11.94.28 29.72-2.1-.47a77.8 77.8 0 0 0-16.72-2.04c-4.96 0-9.61.67-13.96 2l-2.34.73L83.5 4.96l9.72 18.37L102.94 0Zm-1.87 13.39-7.5 17.96-7.3-13.8-1.03 19.93.22-.06a51.56 51.56 0 0 1 12.1-1.45h.31c4.58 0 9.58.54 15 1.61l.35.07-.15-16.54-9.79 11-2.21-18.72Zm38.86 19.23c.67.2 1.05.89.86 1.56l-.38 1.32c-.17.62-.8 1-1.42.89l-.13-.03a1.26 1.26 0 0 1-.86-1.56l.38-1.32c.19-.66.88-1.05 1.55-.86ZM63.95 31.1l.05.12.7 2.17a1.26 1.26 0 0 1-2.34.9l-.04-.12-.71-2.17a1.26 1.26 0 0 1 2.34-.9Z"
/>
</svg>
</figure>
<!-- / -->
<div class="flex justify-center space-x-2">
<a
class="btn variant-filled"
href="https://skeleton.dev/"
target="_blank"
rel="noreferrer"
>
Launch Documentation
</a>
</div>
<div class="space-y-2">
<p>Try editing the following:</p>
<p><code class="code">/src/routes/+layout.svelte</code></p>
<p><code class="code">/src/routes/+page.svelte</code></p>
</div>
</div>
</div>
<style lang="postcss">
figure {
@apply flex relative flex-col;
}
figure svg,
.img-bg {
@apply w-64 h-64 md:w-80 md:h-80;
}
.img-bg {
@apply absolute z-[-1] rounded-full blur-[50px] transition-all;
animation: pulse 5s cubic-bezier(0, 0, 0, 0.5) infinite,
glow 5s linear infinite;
}
@keyframes glow {
0% {
@apply bg-primary-400/50;
}
33% {
@apply bg-secondary-400/50;
}
66% {
@apply bg-tertiary-400/50;
}
100% {
@apply bg-primary-400/50;
}
}
@keyframes pulse {
50% {
transform: scale(1.5);
}
}
</style>

BIN
frontend/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

19
frontend/svelte.config.js Normal file
View File

@ -0,0 +1,19 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
extensions: ['.svelte'],
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: [ vitePreprocess()],
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

View File

@ -0,0 +1,27 @@
import { join } from 'path'
import type { Config } from 'tailwindcss'
import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography';
import { skeleton } from '@skeletonlabs/tw-plugin'
export default {
darkMode: 'class',
content: ['./src/**/*.{html,js,svelte,ts}', join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}')],
theme: {
extend: {},
},
plugins: [
forms,
typography,
skeleton({
themes: {
preset: [
{
name: 'vintage',
enhancements: true,
},
],
},
}),
],
} satisfies Config;

6
frontend/tests/test.ts Normal file
View File

@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('index page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome to SvelteKit' })).toBeVisible();
});

17
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

7
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { purgeCss } from 'vite-plugin-tailwind-purgecss';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit(), purgeCss()]
});

2082
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

11
justfile Normal file
View File

@ -0,0 +1,11 @@
set dotenv-load := true
FRONTEND_OUT := 'frontend/build'
dev:
cargo run
build:
just frontend/build
cargo build --release
cp -r {FRONTEND_OUT} public

View File

@ -0,0 +1,27 @@
-- Add migration script here
CREATE TABLE Person (
id UUID PRIMARY KEY,
username VARCHAR(64) NOT NULL,
displayname VARCHAR(64),
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
host VARCHAR(64) NOT NULL
);
CREATE TABLE Play (
id UUID PRIMARY KEY,
person UUID REFERENCES Person(id),
started_at TIMESTAMP NOT NULL,
mbid VARCHAR(64),
track_name VARCHAR(64) NOT NULL
);
CREATE TABLE Magic (
token CHAR(64) PRIMARY KEY,
id UUID NOT NULL REFERENCES Person(id),
created_at TIMESTAMP NOT NULL,
max_age INTERVAL NOT NULL DEFAULT '1 day'
);
CREATE TABLE Token (
refresh_token VARCHAR PRIMARY KEY
);

0
src/api/lastfm_shim.rs Normal file
View File

75
src/api/magic.rs Normal file
View File

@ -0,0 +1,75 @@
use std::sync::Arc;
use axum::{
extract::Path,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use crate::{
config::Config,
db::{AvailableDb, Database},
jwt::make_jwt,
utils::internal_error,
worker::{Message, Queue},
AppCtx,
};
#[derive(Debug, Clone, Serialize)]
struct JwtResponse {
token: String,
}
#[derive(Debug, Clone, Deserialize)]
struct LoginRequest {
username: String,
}
pub fn routes() -> Router<AppCtx> {
Router::new()
.route("/finish/:token", get(handle_login))
.route("/generate", post(start_login))
}
async fn start_login(
q: Queue,
Database(mut conn): Database,
Json(LoginRequest { username }): Json<LoginRequest>,
) -> Result<(), (StatusCode, String)> {
let user = conn
.get_or_create_user_by_name(&username)
.await
.map_err(internal_error)?;
let token = conn
.create_magic_token(&user.id)
.await
.map_err(internal_error)?;
q.send(Message::SendToken {
user: user.username,
token: token,
})
.await;
Ok(())
}
async fn handle_login(
Path(token): Path<String>,
cfg: Arc<Config>,
Database(mut conn): Database,
) -> Result<Json<JwtResponse>, (StatusCode, String)> {
let user = conn.check_magic_token(&token).await.map_err(|e| {
tracing::error!("Error logging in with token '{token}': ");
(StatusCode::UNAUTHORIZED, "Invalid Token".to_owned())
})?;
let jwt = make_jwt(&*cfg, &user.to_string()).map_err(internal_error)?;
Ok(Json(JwtResponse { token: jwt }))
}

14
src/api/mod.rs Normal file
View File

@ -0,0 +1,14 @@
use axum::Router;
use sqlx::PgPool;
use crate::{db::AvailableDb, AppCtx};
mod magic;
mod model;
mod track;
pub fn routes() -> Router<AppCtx> {
Router::new()
// .nest("/track", track::routes())
.nest("/magic", magic::routes())
}

6
src/api/model.rs Normal file
View File

@ -0,0 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Play {
mbid: String,
}

6
src/api/track/mod.rs Normal file
View File

@ -0,0 +1,6 @@
use axum::Router;
use sqlx::PgPool;
pub fn routes() -> Router<PgPool> {
Router::new()
}

40
src/config.rs Normal file
View File

@ -0,0 +1,40 @@
use std::sync::Arc;
use async_trait::async_trait;
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
use figment::{
providers::{Env, Format, Toml, Yaml},
Figment,
};
use serde::{Deserialize, Serialize};
use crate::AppCtx;
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct Config {
pub hmac_key: String,
}
#[async_trait]
impl FromRequestParts<AppCtx> for Arc<Config> {
type Rejection = StatusCode;
async fn from_request_parts(
_parts: &mut Parts,
state: &AppCtx,
) -> Result<Self, Self::Rejection> {
Ok(state.config.clone())
}
}
pub fn make_config() -> Result<Config, anyhow::Error> {
Ok(Figment::new()
.join(Toml::file("Config.toml"))
.merge(Yaml::file("Config.yml"))
.merge(Yaml::file("Config.yaml"))
.merge(Env::prefixed("OMEGA__"))
.extract()?)
}

79
src/db/mod.rs Normal file
View File

@ -0,0 +1,79 @@
mod model;
pub mod postgres;
pub mod surreal;
use crate::error::Result;
use async_trait::async_trait;
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
use sqlx::{types::Uuid, PgPool};
use surrealdb::{engine::any::Any, Surreal};
use tracing::error;
use crate::AppCtx;
use self::model::{DbUser, NewUser};
#[derive(Clone, Debug)]
pub enum AvailableDb {
Postgres(PgPool),
}
#[async_trait]
pub trait DatabaseAdapter {
async fn get_user_by_name(&mut self, name: &str) -> Result<DbUser>;
async fn get_or_create_user_by_name(&mut self, name: &str) -> Result<DbUser>;
async fn create_user(&mut self, name: NewUser) -> Result<DbUser>;
async fn create_track(&self);
async fn create_magic_token(&mut self, user_id: &Uuid) -> Result<String>;
async fn check_magic_token(&mut self, token: &str) -> Result<Uuid>;
}
pub struct Database(pub Box<dyn DatabaseAdapter + Send + Sync + 'static>);
#[async_trait]
impl FromRequestParts<AppCtx> for Database {
type Rejection = (StatusCode, String);
async fn from_request_parts(
_parts: &mut Parts,
state: &AppCtx,
) -> Result<Self, Self::Rejection> {
match &state.db {
AvailableDb::Postgres(pool) => {
let conn = pool.acquire().await.map_err(|e| {
error!("{e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error".to_owned(),
)
})?;
Ok(Self(Box::new(conn)))
}
}
}
}
// #[async_trait]
// impl FromRequestParts<Surreal<Any>> for DatabaseConnection {
// type Rejection = (StatusCode, String);
// async fn from_request_parts(
// _parts: &mut Parts,
// state: &Surreal<Any>,
// ) -> Result<Self, Self::Rejection> {
// let conn = Surreal::from_ref(state);
// // let conn = pool.acquire().await.map_err(|e| {
// // error!("{e}");
// // (
// // StatusCode::INTERNAL_SERVER_ERROR,
// // "Internal Server Error".to_owned(),
// // )
// // })?;
// Ok(Self(Box::new(conn)))
// }
// }

28
src/db/model.rs Normal file
View File

@ -0,0 +1,28 @@
use serde::{Deserialize, Serialize};
use sqlx::types::Uuid;
use surrealdb::sql::Thing;
#[derive(Debug, Deserialize, Serialize)]
pub struct DbUser {
pub id: Uuid,
pub username: String,
pub displayname: Option<String>,
pub host: String,
pub is_admin: bool,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct DbPlay<'a> {
user: Thing,
started_at: &'a str,
mbid: Option<&'a str>,
track_name: &'a str,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct NewUser {
pub username: String,
pub host: String,
pub name: Option<String>,
pub is_admin: bool,
}

98
src/db/postgres.rs Normal file
View File

@ -0,0 +1,98 @@
use lazy_static::lazy_static;
use rand::{distributions::Alphanumeric, prelude::*};
use std::{ops::DerefMut, str::FromStr};
use crate::error::Result;
use async_trait::async_trait;
use sqlx::{pool::PoolConnection, query, query_as, types::Uuid, PgPool, Postgres};
use super::{
model::{DbUser, NewUser},
DatabaseAdapter,
};
pub async fn connect(url: impl AsRef<str>) -> Result<PgPool> {
let pool = sqlx::PgPool::connect(url.as_ref()).await?;
sqlx::migrate!().run(&mut pool.acquire().await?).await?;
Ok(pool)
}
#[async_trait]
impl DatabaseAdapter for PoolConnection<Postgres> {
async fn get_user_by_name(&mut self, name: &str) -> Result<DbUser> {
let res = query_as!(
DbUser,
"SELECT * FROM Person WHERE username = $1 LIMIT 1",
name
)
.fetch_one(self.deref_mut())
.await?;
Ok(res)
}
async fn get_or_create_user_by_name(&mut self, name: &str) -> Result<DbUser> {
match query_as!(
DbUser,
"SELECT * FROM Person WHERE username = $1 LIMIT 1",
name
)
.fetch_one(self.deref_mut())
.await
{
Err(sqlx::Error::RowNotFound) => {
let Some((displayname, host)) = name.split_once('@') else {
return Err(sqlx::Error::RowNotFound.into());
};
self.create_user(NewUser {
username: name.to_string(),
host: host.to_string(),
name: Some(displayname.to_string()),
is_admin: false,
})
.await
}
other => Ok(other?),
}
}
async fn create_user(&mut self, user: NewUser) -> Result<DbUser> {
Ok(query_as!(
DbUser,
"INSERT INTO Person VALUES (gen_random_uuid(), $1, $2, $3, $4) RETURNING *",
user.username,
user.name,
user.is_admin,
user.host,
)
.fetch_one(self.deref_mut())
.await?)
}
async fn create_track(&self) {}
async fn create_magic_token(&mut self, user_id: &Uuid) -> Result<String> {
let token: String = thread_rng()
.sample_iter(Alphanumeric)
.take(64)
.map(char::from)
.collect();
query!(
"INSERT INTO Magic VALUES ($1, $2, now(), '15 min')",
token,
user_id
)
.execute(self.deref_mut())
.await?;
Ok(token)
}
async fn check_magic_token(&mut self, token: &str) -> Result<Uuid> {
let user = query!(
"SELECT id FROM Magic WHERE token = $1 AND now() - created_at < max_age ",
token
)
.fetch_one(self.deref_mut())
.await?;
Ok(user.id)
}
}

43
src/db/surreal.rs Normal file
View File

@ -0,0 +1,43 @@
use anyhow::Result;
use async_trait::async_trait;
use surrealdb::{engine::any::Any, opt::auth::Root, Error, Surreal};
use tracing::error;
use super::{
model::{DbUser, NewUser},
DatabaseAdapter,
};
pub async fn connect(remote: &str) -> Result<Surreal<Any>> {
let conn: Surreal<Any> = Surreal::init();
conn.connect(remote).await?;
//TODO: configurable here
conn.signin(Root {
username: "root",
password: "root",
})
.await?;
conn.use_ns("omegafm").use_db("omegafm").await?;
Ok(conn)
}
// #[async_trait]
// impl DatabaseAdapter for Surreal<Any> {
// async fn migrate(&self) {
// if let Err(e) = self.query("DEFINE TABLE user;").await {
// error!("{e:?}");
// }
// if let Err(e) = self.query("DEFINE TABLE play;").await {
// error!("{e:?}");
// }
// }
// async fn creat_user(&self, user: NewUser) {
// let rec: Result<Vec, Error> = self.create("user").content(user).await;
// }
// async fn create_track(&self) {}
// }

17
src/error.rs Normal file
View File

@ -0,0 +1,17 @@
use std::string::FromUtf8Error;
use thiserror::Error;
pub type Result<T, E = Error> = core::result::Result<T, E>;
#[derive(Error, Debug)]
pub enum Error {
#[error("Error accessing database: {0}")]
DatabaseError(#[from] sqlx::Error),
#[error("Error migrating database: {0}")]
MigrationError(#[from] sqlx::migrate::MigrateError),
#[error("Error parsing UUID: {0}")]
UuidError(#[from] sqlx::types::uuid::Error),
#[error("String wasn't valid UTF8: {0}")]
Utf8Error(#[from] FromUtf8Error),
}

46
src/jwt.rs Normal file
View File

@ -0,0 +1,46 @@
use std::{
alloc::System,
time::{Duration, SystemTime},
};
use anyhow::anyhow;
use josekit::{
jws::{JwsHeader, HS256},
jwt::{self, JwtPayload, JwtPayloadValidator},
JoseError,
};
use sqlx::types::Uuid;
use crate::config::Config;
pub fn make_jwt(cfg: &Config, user: &str) -> Result<String, JoseError> {
let signer = HS256.signer_from_bytes(cfg.hmac_key.as_bytes())?;
let mut header = JwsHeader::new();
header.set_token_type("JWT");
let mut payload = JwtPayload::new();
payload.set_subject(user);
payload.set_expires_at(&(SystemTime::now() + Duration::from_secs(432000))); // expires in 5 days
payload.set_issued_at(&SystemTime::now());
payload.set_audience(vec!["user_token"]);
let token = jwt::encode_with_signer(&payload, &header, &signer)?;
Ok(token)
}
pub fn verify_jwt(cfg: &Config, token: &str) -> Result<Uuid, anyhow::Error> {
let verifier = HS256.verifier_from_bytes(cfg.hmac_key.as_bytes())?;
let (payload, header) = jwt::decode_with_verifier(token, &verifier)?;
let mut validator = JwtPayloadValidator::new();
validator.set_base_time(SystemTime::now());
validator.validate(&payload)?;
let Some(id) = payload.subject() else {
return Err(anyhow!("Missing Subject claim"));
};
Ok(Uuid::try_parse(id)?)
}

142
src/main.rs Normal file
View File

@ -0,0 +1,142 @@
use std::sync::Arc;
use axum::{extract::Query, http::StatusCode, routing::get, Json, Router};
use config::{make_config, Config};
use db::{AvailableDb, Database};
use error::Error;
use serde::{Deserialize, Serialize};
use tower_http::services::{ServeDir, ServeFile};
use tracing::{error, info};
use tracing_subscriber::prelude::*;
use utils::internal_error;
use worker::Queue;
mod api;
mod config;
mod db;
mod error;
mod jwt;
mod utils;
mod worker;
#[derive(Clone, Debug)]
pub struct AppCtx {
db: AvailableDb,
q: Queue,
config: Arc<Config>,
}
#[tokio::main]
async fn main() {
// init logging
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::from_default_env())
.with(tracing_subscriber::fmt::layer())
.init();
let q = worker::start().await.expect("Worker");
// let db_conn = db::surreal::connect("localhost:8000").await.expect("DB");
let db_conn = db::postgres::connect("postgres://postgres:password@localhost")
.await
.expect("db connection");
let router = Router::new()
.nest("/api", api::routes())
.route("/.well-known/webfinger", get(webfinger))
.with_state(AppCtx {
db: db::AvailableDb::Postgres(db_conn),
config: Arc::new(make_config().expect("config")),
q,
})
.fallback_service(
ServeDir::new("public").not_found_service(ServeFile::new("public/index.html")),
);
match tokio::net::TcpListener::bind("0.0.0.0:8675").await {
Ok(socket) => {
info!("Starting server on {}", socket.local_addr().expect("addr"));
axum::serve(socket, router)
.with_graceful_shutdown(shutdown())
.await
.expect("Error running server");
}
Err(e) => {
error!("Error binding address: {e}");
return;
}
}
}
async fn shutdown() {
tokio::signal::ctrl_c().await.expect("sigint listener");
}
#[derive(Debug, Clone, Deserialize)]
struct WFQuery {
resource: String,
}
/// WebFinger result that may serialized or deserialized to JSON
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct Webfinger {
/// The subject of this WebFinger result.
///
/// It is an `acct:` URI
pub subject: String,
/// A list of aliases for this WebFinger result.
#[serde(default)]
pub aliases: Vec<String>,
/// Links to places where you may find more information about this resource.
pub links: Vec<Link>,
}
/// Structure to represent a WebFinger link
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct Link {
/// Tells what this link represents
pub rel: String,
/// The actual URL of the link
#[serde(skip_serializing_if = "Option::is_none")]
pub href: Option<String>,
/// The Link may also contain an URL template, instead of an actual URL
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
/// The mime-type of this link.
///
/// If you fetch this URL, you may want to use this value for the Accept header of your HTTP
/// request.
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
}
async fn webfinger(
Query(q): Query<WFQuery>,
Database(mut conn): Database,
) -> Result<Json<Webfinger>, (StatusCode, String)> {
let (_, name) = q
.resource
.split_once(':')
.ok_or_else(|| (StatusCode::BAD_REQUEST, "Malformed Resource".to_owned()))?;
match conn.get_user_by_name(name).await {
Err(Error::DatabaseError(sqlx::Error::ColumnNotFound(_))) => {
Err((StatusCode::NOT_FOUND, "Unknown user".into()))
}
Err(e) => Err(internal_error(e)),
Ok(user) => Ok(Json(Webfinger {
subject: q.resource,
aliases: vec![user.username.clone(), user.displayname.unwrap_or_default()],
links: vec![Link {
rel: "http://webfinger.net/rel/profile-page".to_string(),
href: format!("http://localhost:8675/{}", user.username).into(),
template: None,
mime_type: None,
}],
})),
}
}

7
src/utils.rs Normal file
View File

@ -0,0 +1,7 @@
use axum::http::StatusCode;
use tracing::error;
pub fn internal_error<E: std::error::Error>(err: E) -> (StatusCode, String) {
error!("{err:?}");
(StatusCode::INTERNAL_SERVER_ERROR, "Server Error".into())
}

6
src/worker/ap.rs Normal file
View File

@ -0,0 +1,6 @@
use anyhow::Result;
pub async fn send_token(user: &str, token: &str) -> Result<()> {
println!("{token}");
Ok(())
}

126
src/worker/mod.rs Normal file
View File

@ -0,0 +1,126 @@
use std::{
collections::VecDeque,
sync::Arc,
time::{Duration, SystemTime},
};
use async_trait::async_trait;
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
use celery::Celery;
use tokio::{
spawn,
sync::{Mutex, Notify},
task::spawn_blocking,
};
use tracing::{debug, error, info, warn};
use crate::AppCtx;
pub mod ap;
#[derive(Debug, Clone)]
pub struct Queue {
q: Arc<Mutex<VecDeque<Task>>>,
notify: Arc<tokio::sync::Notify>,
}
impl Queue {
pub async fn send(&self, msg: Message) {
self.q.lock().await.push_back(Task::new(msg));
self.notify.notify_one();
}
}
#[async_trait]
impl FromRequestParts<AppCtx> for Queue {
type Rejection = StatusCode;
async fn from_request_parts(
_parts: &mut Parts,
state: &AppCtx,
) -> Result<Self, Self::Rejection> {
Ok(state.q.clone())
}
}
#[derive(Debug, Clone)]
pub enum Message {
SendToken { user: String, token: String },
}
#[derive(Debug, Clone)]
struct Task {
tries: u16,
delay: Duration,
last_attempt: Option<SystemTime>,
msg: Message,
}
impl Task {
fn new(msg: Message) -> Self {
Self {
tries: 0,
delay: Duration::ZERO,
last_attempt: None,
msg,
}
}
}
pub async fn start() -> Result<Queue, anyhow::Error> {
info!("Starting worker task");
// let app = celery::app!(
// broker = AMQPBroker { "amqp://127.0.0.1:5672".to_string() },
// tasks = [
// send_token
// ],
// task_routes = [
// "send_token" => "outbound",
// "*" => "celery",
// ],
// prefetch_count = 2,
// heartbeat = Some(10),
// )
// .await?;
let q = Arc::new(Mutex::new(VecDeque::<Task>::new()));
let notify = Arc::new(Notify::new());
let consumer = q.clone();
let listener = notify.clone();
spawn(async move {
loop {
listener.notified().await;
if let Some(task) = consumer.lock().await.pop_front() {
debug!("Recieved message {:?}", task.msg);
if let Err(e) = dispatch(&task.msg).await {
error!("Worker errorL {e}");
consumer.lock().await.push_back(task);
}
} else {
warn!("Notified on empty queue");
}
}
});
// let consumer = app.clone();
// spawn(async move {
// if let Err(e) = consumer.consume_from(&["celery", "outbound"]).await {
// error!("Worker error: {e}");
// }
// });
Ok(Queue { q, notify })
}
async fn dispatch(msg: &Message) -> anyhow::Result<()> {
match msg {
Message::SendToken { user, token } => ap::send_token(user, token).await?,
}
Ok(())
}