Compare commits

..

8 Commits

Author SHA1 Message Date
Priec
eaccfada73 cog hamburger overlay that blocks other and so on:
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-05-17 15:25:57 +02:00
Priec
059fdf1b49 ui from the tenis kurt website 2026-05-17 15:21:07 +02:00
Priec
622afc310d UI 2026-05-17 15:16:51 +02:00
Priec
27887bf664 about page 2026-05-17 15:05:47 +02:00
Priec
7176e01e8c about page 2026-05-17 15:03:44 +02:00
Priec
0bfd2f8674 removing rbac cos its not needed at all 2026-05-17 14:58:13 +02:00
Priec
35f0e7af00 commits being migrated: 2026-05-17 14:13:06 +02:00
Priec
f236f655d1 can run it now 2026-05-17 13:59:52 +02:00
57 changed files with 8617 additions and 12 deletions

5982
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,7 @@ sea-orm = { version = "1.1", features = [
"macros",
] }
chrono = { version = "0.4" }
time = { version = "0.3" }
validator = { version = "0.20" }
uuid = { version = "1.6", features = ["v4"] }
include_dir = { version = "0.7" }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,19 @@
{% extends "admin/base.html" %}
{% block title %}Edit About{% endblock title %}
{% block content %}
<h1>Edit About</h1>
<form method="post" action="/admin/about">
<label>
Title
<input type="text" name="title" value="{{ page.title }}" required>
</label>
<label>
Content
<textarea name="content" rows="16" required>{{ page.content }}</textarea>
</label>
<button type="submit">Save</button>
</form>
{% endblock content %}

View File

@@ -0,0 +1,114 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Admin{% endblock title %}</title>
<script>
function applyTheme(t) {
var dark = t === 'dark'
|| (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
}
function highlightTheme(t) {
document.querySelectorAll('[data-theme-opt]').forEach(function (b) {
var on = b.getAttribute('data-theme-opt') === t;
b.classList.toggle('active', on);
var chk = b.querySelector('.opt-check');
if (chk) chk.classList.toggle('hidden', !on);
});
}
function setTheme(t) {
localStorage.setItem('theme', t);
applyTheme(t);
highlightTheme(t);
}
applyTheme(localStorage.getItem('theme') || 'system');
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
if ((localStorage.getItem('theme') || 'system') === 'system') applyTheme('system');
});
document.addEventListener('DOMContentLoaded', function () {
highlightTheme(localStorage.getItem('theme') || 'system');
});
</script>
<link href="/static/css/app.css" rel="stylesheet" type="text/css">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
.btn { --animation-btn: 0; --btn-focus-scale: 1; }
#nav-backdrop { display: none; }
@media (max-width: 767px) {
#nav-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 40;
background-color: rgba(0, 0, 0, 0.25);
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0s linear 0.2s;
}
.navbar:has(.dropdown:focus-within) ~ #nav-backdrop {
opacity: 1;
visibility: visible;
transition: opacity 0.15s ease, visibility 0s;
}
}
</style>
</head>
<body class="min-h-screen bg-base-200 font-sans text-base-content antialiased">
<header class="navbar bg-base-100 shadow-sm">
<nav class="mx-auto flex w-full max-w-6xl items-center justify-between gap-2 px-4">
<a href="/admin/dashboard" class="min-w-0 truncate text-lg font-bold">Admin</a>
<div class="hidden items-center gap-1 md:flex">
<a href="/admin/dashboard" class="btn btn-ghost btn-sm">Dashboard</a>
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm">Blog</a>
<a href="/admin/about" class="btn btn-ghost btn-sm">About</a>
<a href="/" class="btn btn-ghost btn-sm">View site</a>
<form method="post" action="/admin/logout">
<button type="submit" class="btn btn-ghost btn-sm">Logout</button>
</form>
</div>
<div class="flex items-center gap-1">
<div class="dropdown dropdown-end md:hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Menu">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</div>
<div tabindex="0"
class="dropdown-content z-50 mt-3 flex w-52 flex-col gap-1 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
<a href="/admin/dashboard" class="btn btn-ghost btn-sm justify-start">Dashboard</a>
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm justify-start">Blog</a>
<a href="/admin/about" class="btn btn-ghost btn-sm justify-start">About</a>
<a href="/" class="btn btn-ghost btn-sm justify-start">View site</a>
<form method="post" action="/admin/logout">
<button type="submit" class="btn btn-ghost btn-sm w-full justify-start">Logout</button>
</form>
</div>
</div>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Settings" title="Settings">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</div>
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
<li class="menu-title">Theme</li>
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">System <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">Light <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">Dark <span class="opt-check ml-auto hidden"></span></button></li>
</ul>
</div>
</div>
</nav>
</header>
<div id="nav-backdrop" aria-hidden="true"></div>
<main class="mx-auto max-w-6xl px-4 py-6">
{% block content %}{% endblock content %}
</main>
</body>
</html>

View File

@@ -0,0 +1,31 @@
{% extends "admin/base.html" %}
{% block title %}Edit Article{% endblock title %}
{% block content %}
<h1>Edit Article</h1>
<form method="post" action="/admin/blog/articles/{{ article.id }}">
<label>
Title
<input type="text" name="title" value="{{ article.title }}" required>
</label>
<label>
Excerpt
<textarea name="excerpt" rows="4">{% if article.excerpt %}{{ article.excerpt }}{% endif %}</textarea>
</label>
<label>
Content
<textarea name="content" rows="18" required>{{ article.content }}</textarea>
</label>
<label>
Featured image id
<input type="text" name="featured_image_id" value="{% if article.featured_image_id %}{{ article.featured_image_id }}{% endif %}">
</label>
<label>
<input type="checkbox" name="published" {% if article.published %}checked{% endif %}>
Published
</label>
<button type="submit">Save</button>
</form>
{% endblock content %}

View File

@@ -0,0 +1,36 @@
{% extends "admin/base.html" %}
{% block title %}Blog Articles{% endblock title %}
{% block content %}
<h1>Blog Articles</h1>
<p><a href="/admin/blog/articles/new">New article</a></p>
{% if articles | length > 0 %}
<table>
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for article in articles %}
<tr>
<td>{{ article.title }}</td>
<td>{% if article.published %}Published{% else %}Draft{% endif %}</td>
<td>
<a href="/admin/blog/articles/{{ article.id }}/edit">Edit</a>
<form method="post" action="/admin/blog/articles/{{ article.id }}/delete">
<button type="submit">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No articles yet.</p>
{% endif %}
{% endblock content %}

View File

@@ -0,0 +1,31 @@
{% extends "admin/base.html" %}
{% block title %}New Article{% endblock title %}
{% block content %}
<h1>New Article</h1>
<form method="post" action="/admin/blog/articles">
<label>
Title
<input type="text" name="title" required>
</label>
<label>
Excerpt
<textarea name="excerpt" rows="4"></textarea>
</label>
<label>
Content
<textarea name="content" rows="18" required></textarea>
</label>
<label>
Featured image id
<input type="text" name="featured_image_id">
</label>
<label>
<input type="checkbox" name="published">
Published
</label>
<button type="submit">Create</button>
</form>
{% endblock content %}

View File

@@ -0,0 +1,13 @@
{% extends "admin/base.html" %}
{% block title %}Admin{% endblock title %}
{% block content %}
<h1>Admin</h1>
<p>Logged in as {{ admin.email }}</p>
<ul>
<li><a href="/admin/blog/articles">Manage blog articles</a></li>
<li><a href="/admin/about">Edit about page</a></li>
</ul>
{% endblock content %}

View File

@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Admin login{% endblock title %}
{% block content %}
<div class="mx-auto mt-8 max-w-sm">
<div class="card border border-base-300 bg-base-100 shadow-sm">
<div class="card-body">
<h1 class="card-title">Admin login</h1>
{% if error %}
<div class="alert alert-error">
<span>Invalid email or password.</span>
</div>
{% endif %}
<form method="post" action="/admin/login" class="space-y-2">
<div class="form-control">
<label class="label"><span class="label-text">Email</span></label>
<input type="email" name="email" required class="input input-bordered w-full">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Password</span></label>
<input type="password" name="password" required class="input input-bordered w-full">
</div>
<button class="btn btn-neutral mt-2 w-full">Login</button>
</form>
</div>
</div>
</div>
{% endblock content %}

108
assets/views/base.html Normal file
View File

@@ -0,0 +1,108 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Universal Web{% endblock title %}</title>
<script>
function applyTheme(t) {
var dark = t === 'dark'
|| (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
}
function highlightTheme(t) {
document.querySelectorAll('[data-theme-opt]').forEach(function (b) {
var on = b.getAttribute('data-theme-opt') === t;
b.classList.toggle('active', on);
var chk = b.querySelector('.opt-check');
if (chk) chk.classList.toggle('hidden', !on);
});
}
function setTheme(t) {
localStorage.setItem('theme', t);
applyTheme(t);
highlightTheme(t);
}
applyTheme(localStorage.getItem('theme') || 'system');
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
if ((localStorage.getItem('theme') || 'system') === 'system') applyTheme('system');
});
document.addEventListener('DOMContentLoaded', function () {
highlightTheme(localStorage.getItem('theme') || 'system');
});
</script>
<link href="/static/css/app.css" rel="stylesheet" type="text/css">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
.btn { --animation-btn: 0; --btn-focus-scale: 1; }
#nav-backdrop { display: none; }
@media (max-width: 767px) {
#nav-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 40;
background-color: rgba(0, 0, 0, 0.25);
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0s linear 0.2s;
}
.navbar:has(.dropdown:focus-within) ~ #nav-backdrop {
opacity: 1;
visibility: visible;
transition: opacity 0.15s ease, visibility 0s;
}
}
</style>
</head>
<body class="min-h-screen bg-base-200 font-sans text-base-content antialiased">
<header class="navbar bg-base-100 shadow-sm">
<nav class="mx-auto flex w-full max-w-6xl items-center justify-between gap-2 px-4">
<a href="/" class="min-w-0 truncate text-lg font-bold">Universal Web</a>
<div class="hidden items-center gap-1 md:flex">
<a href="/" class="btn btn-ghost btn-sm">Home</a>
<a href="/about" class="btn btn-ghost btn-sm">About</a>
<a href="/blog" class="btn btn-ghost btn-sm">Blog</a>
<a href="/admin/login" class="btn btn-ghost btn-sm">Admin</a>
</div>
<div class="flex items-center gap-1">
<div class="dropdown dropdown-end md:hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Menu">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</div>
<div tabindex="0"
class="dropdown-content z-50 mt-3 flex w-52 flex-col gap-1 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
<a href="/" class="btn btn-ghost btn-sm justify-start">Home</a>
<a href="/about" class="btn btn-ghost btn-sm justify-start">About</a>
<a href="/blog" class="btn btn-ghost btn-sm justify-start">Blog</a>
<a href="/admin/login" class="btn btn-ghost btn-sm justify-start">Admin</a>
</div>
</div>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Settings" title="Settings">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</div>
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
<li class="menu-title">Theme</li>
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">System <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">Light <span class="opt-check ml-auto hidden"></span></button></li>
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">Dark <span class="opt-check ml-auto hidden"></span></button></li>
</ul>
</div>
</div>
</nav>
</header>
<div id="nav-backdrop" aria-hidden="true"></div>
<main class="mx-auto max-w-6xl px-4 py-6">
{% block content %}{% endblock content %}
</main>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Blog{% endblock title %}
{% block content %}
<h1>Blog</h1>
{% if articles | length > 0 %}
<ul>
{% for article in articles %}
<li>
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No published posts yet.</p>
{% endif %}
{% endblock content %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}{{ article.title }}{% endblock title %}
{% block content %}
<article>
<h1>{{ article.title }}</h1>
<p>Views: {{ article.view_count }}</p>
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %}
<div>{{ article.content | linebreaksbr | safe }}</div>
</article>
{% endblock content %}

View File

@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Home{% endblock title %}
{% block content %}
<h1>Home</h1>
<section>
<h2>Latest posts</h2>
{% if articles | length > 0 %}
<ul>
{% for article in articles %}
<li>
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No published posts yet.</p>
{% endif %}
</section>
{% endblock content %}

View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}{{ page.title }}{% endblock title %}
{% block content %}
<article>
<h1>{{ page.title }}</h1>
<div>{{ page.content | linebreaksbr | safe }}</div>
</article>
{% endblock content %}

View File

@@ -71,7 +71,7 @@ mailer:
# Database Configuration
database:
# Database connection URI
uri: {{ get_env(name="DATABASE_URL", default="postgres://loco:loco@localhost:5432/universal_web_development") }}
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/universal_web_development") }}
# When enabled, the sql query will be logged.
enable_logging: false
# Set the timeout duration when acquiring a connection.
@@ -93,7 +93,14 @@ database:
auth:
# JWT authentication
jwt:
location:
- from: Cookie
name: auth_token
- from: Bearer
# Secret key for token generation and verification
secret: A6ECni63rt2Jb00tX9Hf
# Token expiration time in seconds
expiration: 604800 # 7 days
settings:
admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }}

View File

@@ -68,7 +68,7 @@ mailer:
# Database Configuration
database:
# Database connection URI
uri: {{ get_env(name="DATABASE_URL", default="postgres://loco:loco@localhost:5432/universal_web_test") }}
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/universal_web_test") }}
# When enabled, the sql query will be logged.
enable_logging: false
# Set the timeout duration when acquiring a connection.
@@ -90,7 +90,14 @@ database:
auth:
# JWT authentication
jwt:
location:
- from: Cookie
name: auth_token
- from: Bearer
# Secret key for token generation and verification
secret: 0yWwoflcGiAhonIzhQyQ
# Token expiration time in seconds
expiration: 604800 # 7 days
settings:
admin_email: admin@example.com

View File

@@ -2,6 +2,17 @@
#![allow(clippy::wildcard_imports)]
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_users;
mod m20260517_000001_add_theme_to_users;
mod m20260517_000002_user_roles;
mod m20260517_000003_blog_articles;
mod m20260517_000004_audit_logs;
mod m20260517_000005_audio_albums;
mod m20260517_000006_audio_tracks;
mod m20260517_000007_audio_tags;
mod m20260517_000008_audio_track_tags;
mod m20260517_000009_simple_constraints;
mod m20260517_000010_drop_user_roles;
mod m20260517_000011_site_pages;
pub struct Migrator;
@@ -10,6 +21,17 @@ impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20220101_000001_users::Migration),
Box::new(m20260517_000001_add_theme_to_users::Migration),
Box::new(m20260517_000002_user_roles::Migration),
Box::new(m20260517_000003_blog_articles::Migration),
Box::new(m20260517_000004_audit_logs::Migration),
Box::new(m20260517_000005_audio_albums::Migration),
Box::new(m20260517_000006_audio_tracks::Migration),
Box::new(m20260517_000007_audio_tags::Migration),
Box::new(m20260517_000008_audio_track_tags::Migration),
Box::new(m20260517_000009_simple_constraints::Migration),
Box::new(m20260517_000010_drop_user_roles::Migration),
Box::new(m20260517_000011_site_pages::Migration),
// inject-above (do not remove this comment)
]
}

View File

@@ -0,0 +1,24 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
add_column(
m,
"users",
"theme",
ColType::StringLenWithDefault(20, "light".to_string()),
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
remove_column(m, "users", "theme").await?;
Ok(())
}
}

View File

@@ -0,0 +1,97 @@
use sea_orm_migration::{prelude::*, sea_orm::ConnectionTrait, sea_query::Expr};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum UserRoles {
Table,
UserId,
Role,
AssignedBy,
AssignedAt,
}
#[derive(DeriveIden)]
enum Users {
Table,
Id,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.create_table(
Table::create()
.table(UserRoles::Table)
.if_not_exists()
.col(ColumnDef::new(UserRoles::UserId).integer().not_null())
.col(ColumnDef::new(UserRoles::Role).string_len(50).not_null())
.col(ColumnDef::new(UserRoles::AssignedBy).integer().null())
.col(
ColumnDef::new(UserRoles::AssignedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.primary_key(
Index::create()
.name("pk-user_roles")
.col(UserRoles::UserId)
.col(UserRoles::Role),
)
.foreign_key(
ForeignKey::create()
.name("fk-user_roles-user_id-to-users")
.from(UserRoles::Table, UserRoles::UserId)
.to(Users::Table, Users::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk-user_roles-assigned_by-to-users")
.from(UserRoles::Table, UserRoles::AssignedBy)
.to(Users::Table, Users::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::NoAction),
)
.to_owned(),
)
.await?;
m.create_index(
Index::create()
.name("idx-user_roles-user_id")
.table(UserRoles::Table)
.col(UserRoles::UserId)
.to_owned(),
)
.await?;
let sql = match m.get_database_backend() {
sea_orm_migration::sea_orm::DatabaseBackend::Postgres => {
"INSERT INTO user_roles (user_id, role, assigned_at) \
SELECT id, 'user', CURRENT_TIMESTAMP FROM users \
ON CONFLICT (user_id, role) DO NOTHING"
}
sea_orm_migration::sea_orm::DatabaseBackend::Sqlite => {
"INSERT OR IGNORE INTO user_roles (user_id, role, assigned_at) \
SELECT id, 'user', CURRENT_TIMESTAMP FROM users"
}
sea_orm_migration::sea_orm::DatabaseBackend::MySql => {
"INSERT IGNORE INTO user_roles (user_id, role, assigned_at) \
SELECT id, 'user', CURRENT_TIMESTAMP FROM users"
}
};
m.get_connection().execute_unprepared(sql).await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_table(Table::drop().table(UserRoles::Table).to_owned())
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,126 @@
use sea_orm_migration::{prelude::*, sea_query::Expr};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum BlogArticles {
Table,
Id,
Title,
Slug,
Content,
Excerpt,
Published,
AuthorId,
FeaturedImageId,
ViewCount,
CreatedAt,
UpdatedAt,
PublishedAt,
}
#[derive(DeriveIden)]
enum Users {
Table,
Id,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.create_table(
Table::create()
.table(BlogArticles::Table)
.if_not_exists()
.col(ColumnDef::new(BlogArticles::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(BlogArticles::Title).string_len(500).not_null())
.col(
ColumnDef::new(BlogArticles::Slug)
.string_len(500)
.not_null()
.unique_key(),
)
.col(ColumnDef::new(BlogArticles::Content).text().not_null())
.col(ColumnDef::new(BlogArticles::Excerpt).string_len(1000).null())
.col(
ColumnDef::new(BlogArticles::Published)
.boolean()
.not_null()
.default(false),
)
.col(ColumnDef::new(BlogArticles::AuthorId).integer().not_null())
.col(
ColumnDef::new(BlogArticles::FeaturedImageId)
.string_len(500)
.null(),
)
.col(
ColumnDef::new(BlogArticles::ViewCount)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(BlogArticles::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(BlogArticles::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(BlogArticles::PublishedAt)
.timestamp_with_time_zone()
.null(),
)
.foreign_key(
ForeignKey::create()
.name("fk-blog_articles-author_id-to-users")
.from(BlogArticles::Table, BlogArticles::AuthorId)
.to(Users::Table, Users::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
create_index(m, "idx-blog_articles-slug", BlogArticles::Slug).await?;
m.create_index(
Index::create()
.name("idx-blog_articles-published-published_at")
.table(BlogArticles::Table)
.col(BlogArticles::Published)
.col(BlogArticles::PublishedAt)
.to_owned(),
)
.await?;
create_index(m, "idx-blog_articles-author_id", BlogArticles::AuthorId).await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_table(Table::drop().table(BlogArticles::Table).to_owned())
.await?;
Ok(())
}
}
async fn create_index<T>(m: &SchemaManager<'_>, name: &str, col: T) -> Result<(), DbErr>
where
T: Iden + 'static,
{
m.create_index(
Index::create()
.name(name)
.table(BlogArticles::Table)
.col(col)
.to_owned(),
)
.await
}

View File

@@ -0,0 +1,93 @@
use sea_orm_migration::{prelude::*, sea_query::Expr};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum AuditLogs {
Table,
Id,
AdminUserId,
Action,
TargetType,
TargetId,
Details,
IpAddress,
UserAgent,
CreatedAt,
}
#[derive(DeriveIden)]
enum Users {
Table,
Id,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.create_table(
Table::create()
.table(AuditLogs::Table)
.if_not_exists()
.col(ColumnDef::new(AuditLogs::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(AuditLogs::AdminUserId).integer().not_null())
.col(ColumnDef::new(AuditLogs::Action).string_len(100).not_null())
.col(ColumnDef::new(AuditLogs::TargetType).string_len(50).null())
.col(ColumnDef::new(AuditLogs::TargetId).uuid().null())
.col(ColumnDef::new(AuditLogs::Details).json_binary().null())
.col(ColumnDef::new(AuditLogs::IpAddress).inet().null())
.col(ColumnDef::new(AuditLogs::UserAgent).text().null())
.col(
ColumnDef::new(AuditLogs::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk-audit_logs-admin_user_id-to-users")
.from(AuditLogs::Table, AuditLogs::AdminUserId)
.to(Users::Table, Users::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
create_index(m, "idx-audit_logs-admin_user_id", AuditLogs::AdminUserId).await?;
create_index(m, "idx-audit_logs-action", AuditLogs::Action).await?;
m.create_index(
Index::create()
.name("idx-audit_logs-target")
.table(AuditLogs::Table)
.col(AuditLogs::TargetType)
.col(AuditLogs::TargetId)
.to_owned(),
)
.await?;
create_index(m, "idx-audit_logs-created_at", AuditLogs::CreatedAt).await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_table(Table::drop().table(AuditLogs::Table).to_owned())
.await?;
Ok(())
}
}
async fn create_index<T>(m: &SchemaManager<'_>, name: &str, col: T) -> Result<(), DbErr>
where
T: Iden + 'static,
{
m.create_index(
Index::create()
.name(name)
.table(AuditLogs::Table)
.col(col)
.to_owned(),
)
.await
}

View File

@@ -0,0 +1,120 @@
use sea_orm_migration::{prelude::*, sea_query::Expr};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum AudioAlbums {
Table,
Id,
Title,
Slug,
Description,
CoverImageId,
Artist,
ReleaseDate,
Published,
UploaderId,
ViewCount,
CreatedAt,
UpdatedAt,
PublishedAt,
}
#[derive(DeriveIden)]
enum Users {
Table,
Id,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.create_table(
Table::create()
.table(AudioAlbums::Table)
.if_not_exists()
.col(ColumnDef::new(AudioAlbums::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(AudioAlbums::Title).string_len(500).not_null())
.col(
ColumnDef::new(AudioAlbums::Slug)
.string_len(500)
.not_null()
.unique_key(),
)
.col(ColumnDef::new(AudioAlbums::Description).text().null())
.col(
ColumnDef::new(AudioAlbums::CoverImageId)
.string_len(500)
.null(),
)
.col(ColumnDef::new(AudioAlbums::Artist).string_len(500).null())
.col(ColumnDef::new(AudioAlbums::ReleaseDate).date().null())
.col(
ColumnDef::new(AudioAlbums::Published)
.boolean()
.not_null()
.default(false),
)
.col(ColumnDef::new(AudioAlbums::UploaderId).integer().not_null())
.col(
ColumnDef::new(AudioAlbums::ViewCount)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(AudioAlbums::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(AudioAlbums::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(AudioAlbums::PublishedAt)
.timestamp_with_time_zone()
.null(),
)
.foreign_key(
ForeignKey::create()
.name("fk-audio_albums-uploader_id-to-users")
.from(AudioAlbums::Table, AudioAlbums::UploaderId)
.to(Users::Table, Users::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
create_index(m, "idx-audio_albums-slug", AudioAlbums::Slug).await?;
create_index(m, "idx-audio_albums-published", AudioAlbums::Published).await?;
create_index(m, "idx-audio_albums-uploader_id", AudioAlbums::UploaderId).await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_table(Table::drop().table(AudioAlbums::Table).to_owned())
.await?;
Ok(())
}
}
async fn create_index<T>(m: &SchemaManager<'_>, name: &str, col: T) -> Result<(), DbErr>
where
T: Iden + 'static,
{
m.create_index(
Index::create()
.name(name)
.table(AudioAlbums::Table)
.col(col)
.to_owned(),
)
.await
}

View File

@@ -0,0 +1,100 @@
use sea_orm_migration::{prelude::*, sea_query::Expr};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum AudioTracks {
Table,
Id,
AlbumId,
Title,
Slug,
AudioFileId,
TrackNumber,
Duration,
Featured,
PlayCount,
CreatedAt,
UpdatedAt,
}
#[derive(DeriveIden)]
enum AudioAlbums {
Table,
Id,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.create_table(
Table::create()
.table(AudioTracks::Table)
.if_not_exists()
.col(ColumnDef::new(AudioTracks::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(AudioTracks::AlbumId).uuid().not_null())
.col(ColumnDef::new(AudioTracks::Title).string_len(500).not_null())
.col(ColumnDef::new(AudioTracks::Slug).string_len(500).not_null())
.col(
ColumnDef::new(AudioTracks::AudioFileId)
.string_len(500)
.not_null(),
)
.col(ColumnDef::new(AudioTracks::TrackNumber).integer().null())
.col(ColumnDef::new(AudioTracks::Duration).integer().null())
.col(
ColumnDef::new(AudioTracks::Featured)
.boolean()
.not_null()
.default(false),
)
.col(
ColumnDef::new(AudioTracks::PlayCount)
.integer()
.not_null()
.default(0),
)
.col(
ColumnDef::new(AudioTracks::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(AudioTracks::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk-audio_tracks-album_id-to-audio_albums")
.from(AudioTracks::Table, AudioTracks::AlbumId)
.to(AudioAlbums::Table, AudioAlbums::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
m.create_index(
Index::create()
.name("idx-audio_tracks-album_id-slug")
.table(AudioTracks::Table)
.col(AudioTracks::AlbumId)
.col(AudioTracks::Slug)
.unique()
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_table(Table::drop().table(AudioTracks::Table).to_owned())
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,52 @@
use sea_orm_migration::{prelude::*, sea_query::Expr};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum AudioTags {
Table,
Id,
Name,
Slug,
CreatedAt,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.create_table(
Table::create()
.table(AudioTags::Table)
.if_not_exists()
.col(ColumnDef::new(AudioTags::Id).uuid().not_null().primary_key())
.col(
ColumnDef::new(AudioTags::Name)
.string_len(100)
.not_null()
.unique_key(),
)
.col(
ColumnDef::new(AudioTags::Slug)
.string_len(100)
.not_null()
.unique_key(),
)
.col(
ColumnDef::new(AudioTags::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_table(Table::drop().table(AudioTags::Table).to_owned())
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,74 @@
use sea_orm_migration::{prelude::*, sea_query::Expr};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum AudioTrackTags {
Table,
TrackId,
TagId,
CreatedAt,
}
#[derive(DeriveIden)]
enum AudioTracks {
Table,
Id,
}
#[derive(DeriveIden)]
enum AudioTags {
Table,
Id,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.create_table(
Table::create()
.table(AudioTrackTags::Table)
.if_not_exists()
.col(ColumnDef::new(AudioTrackTags::TrackId).uuid().not_null())
.col(ColumnDef::new(AudioTrackTags::TagId).uuid().not_null())
.col(
ColumnDef::new(AudioTrackTags::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.primary_key(
Index::create()
.name("pk-audio_track_tags")
.col(AudioTrackTags::TrackId)
.col(AudioTrackTags::TagId),
)
.foreign_key(
ForeignKey::create()
.name("fk-audio_track_tags-track_id-to-audio_tracks")
.from(AudioTrackTags::Table, AudioTrackTags::TrackId)
.to(AudioTracks::Table, AudioTracks::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk-audio_track_tags-tag_id-to-audio_tags")
.from(AudioTrackTags::Table, AudioTrackTags::TagId)
.to(AudioTags::Table, AudioTags::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_table(Table::drop().table(AudioTrackTags::Table).to_owned())
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,60 @@
use sea_orm_migration::{prelude::*, sea_orm::ConnectionTrait};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum AudioTrackTags {
Table,
TagId,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
if matches!(
m.get_database_backend(),
sea_orm_migration::sea_orm::DatabaseBackend::Postgres
) {
m.get_connection()
.execute_unprepared(
"ALTER TABLE users \
ADD CONSTRAINT chk_users_theme \
CHECK (theme IN ('light', 'dark'))",
)
.await?;
}
m.create_index(
Index::create()
.name("idx-audio_track_tags-tag_id")
.table(AudioTrackTags::Table)
.col(AudioTrackTags::TagId)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_index(
Index::drop()
.name("idx-audio_track_tags-tag_id")
.table(AudioTrackTags::Table)
.to_owned(),
)
.await?;
if matches!(
m.get_database_backend(),
sea_orm_migration::sea_orm::DatabaseBackend::Postgres
) {
m.get_connection()
.execute_unprepared("ALTER TABLE users DROP CONSTRAINT chk_users_theme")
.await?;
}
Ok(())
}
}

View File

@@ -0,0 +1,28 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum UserRoles {
Table,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_table(
Table::drop()
.table(UserRoles::Table)
.if_exists()
.cascade()
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, _m: &SchemaManager) -> Result<(), DbErr> {
Ok(())
}
}

View File

@@ -0,0 +1,65 @@
use sea_orm_migration::{prelude::*, sea_orm::ConnectionTrait, sea_query::Expr};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum SitePages {
Table,
Id,
Slug,
Title,
Content,
CreatedAt,
UpdatedAt,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.create_table(
Table::create()
.table(SitePages::Table)
.if_not_exists()
.col(ColumnDef::new(SitePages::Id).uuid().not_null().primary_key())
.col(
ColumnDef::new(SitePages::Slug)
.string_len(100)
.not_null()
.unique_key(),
)
.col(ColumnDef::new(SitePages::Title).string_len(500).not_null())
.col(ColumnDef::new(SitePages::Content).text().not_null())
.col(
ColumnDef::new(SitePages::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(SitePages::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.to_owned(),
)
.await?;
m.get_connection()
.execute_unprepared(
"INSERT INTO site_pages (id, slug, title, content, created_at, updated_at) \
VALUES (gen_random_uuid(), 'about', 'About', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) \
ON CONFLICT (slug) DO NOTHING",
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_table(Table::drop().table(SitePages::Table).to_owned())
.await?;
Ok(())
}
}

View File

@@ -52,6 +52,10 @@ impl Hooks for App {
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes() // controller routes below
.add_route(controllers::auth::routes())
.add_route(controllers::admin::routes())
.add_route(controllers::blog::routes())
.add_route(controllers::pages::routes())
.add_route(controllers::frontend::routes())
}
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(DownloadWorker::build(ctx)).await?;

57
src/controllers/admin.rs Normal file
View File

@@ -0,0 +1,57 @@
use crate::models::{
_entities::{audio_albums, audio_tracks, audit_logs, blog_articles, users},
users as users_model,
};
use loco_rs::prelude::*;
use sea_orm::{EntityTrait, PaginatorTrait};
use serde::Serialize;
#[derive(Debug, Serialize)]
struct DashboardResponse {
users: u64,
blog_articles: u64,
audio_albums: u64,
audio_tracks: u64,
audit_logs: u64,
}
pub(crate) fn admin_email(ctx: &AppContext) -> Option<&str> {
ctx.config
.settings
.as_ref()
.and_then(|settings| settings.get("admin_email"))
.and_then(|email| email.as_str())
}
pub(crate) fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
}
pub(crate) async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result<users::Model> {
let user = users_model::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
if !is_admin(ctx, &user) {
return unauthorized("admin only");
}
Ok(user)
}
#[debug_handler]
async fn dashboard(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
current_admin(auth, &ctx).await?;
format::json(DashboardResponse {
users: users::Entity::find().count(&ctx.db).await?,
blog_articles: blog_articles::Entity::find().count(&ctx.db).await?,
audio_albums: audio_albums::Entity::find().count(&ctx.db).await?,
audio_tracks: audio_tracks::Entity::find().count(&ctx.db).await?,
audit_logs: audit_logs::Entity::find().count(&ctx.db).await?,
})
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api/admin")
.add("/dashboard", get(dashboard))
}

View File

@@ -6,12 +6,15 @@ use crate::{
},
views::auth::{CurrentResponse, LoginResponse},
};
use axum_extra::extract::cookie::{Cookie, SameSite};
use loco_rs::prelude::*;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use time::Duration as TimeDuration;
pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new();
const AUTH_COOKIE: &str = "auth_token";
fn get_allow_email_domain_re() -> &'static Regex {
EMAIL_DOMAIN_RE.get_or_init(|| {
@@ -19,6 +22,36 @@ fn get_allow_email_domain_re() -> &'static Regex {
})
}
fn admin_email(ctx: &AppContext) -> Option<&str> {
ctx.config
.settings
.as_ref()
.and_then(|settings| settings.get("admin_email"))
.and_then(|email| email.as_str())
}
fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
}
pub(crate) fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> {
Cookie::build((AUTH_COOKIE, token.to_string()))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.max_age(TimeDuration::seconds(max_age_seconds as i64))
.build()
}
pub(crate) fn clear_auth_cookie() -> Cookie<'static> {
Cookie::build((AUTH_COOKIE, ""))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.max_age(TimeDuration::seconds(0))
.build()
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ForgotParams {
pub email: String,
@@ -155,13 +188,20 @@ async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::json(LoginResponse::new(&user, &token))
format::render()
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
}
#[debug_handler]
async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
format::json(CurrentResponse::new(&user))
format::json(CurrentResponse::new(&user, is_admin(&ctx, &user)))
}
#[debug_handler]
async fn logout() -> Result<Response> {
format::render().cookies(&[clear_auth_cookie()])?.json(())
}
/// Magic link authentication provides a secure and passwordless way to log in to the application.
@@ -223,7 +263,9 @@ async fn magic_link_verify(
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::json(LoginResponse::new(&user, &token))
format::render()
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
}
#[debug_handler]
@@ -264,6 +306,7 @@ pub fn routes() -> Routes {
.add("/register", post(register))
.add("/verify/{token}", get(verify))
.add("/login", post(login))
.add("/logout", post(logout))
.add("/forgot", post(forgot))
.add("/reset", post(reset))
.add("/current", get(current))

236
src/controllers/blog.rs Normal file
View File

@@ -0,0 +1,236 @@
use crate::{
controllers::admin,
models::_entities::blog_articles,
};
use chrono::Utc;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Deserialize)]
struct ArticleParams {
title: String,
content: String,
excerpt: Option<String>,
published: Option<bool>,
featured_image_id: Option<String>,
}
#[derive(Debug, Serialize)]
struct ArticleResponse {
id: Uuid,
title: String,
slug: String,
content: String,
excerpt: Option<String>,
published: bool,
author_id: i32,
featured_image_id: Option<String>,
view_count: i32,
created_at: chrono::DateTime<chrono::FixedOffset>,
updated_at: chrono::DateTime<chrono::FixedOffset>,
published_at: Option<chrono::DateTime<chrono::FixedOffset>>,
}
#[derive(Debug, Serialize)]
struct ArticleListResponse {
articles: Vec<ArticleResponse>,
}
impl From<blog_articles::Model> for ArticleResponse {
fn from(article: blog_articles::Model) -> Self {
Self {
id: article.id,
title: article.title,
slug: article.slug,
content: article.content,
excerpt: article.excerpt,
published: article.published,
author_id: article.author_id,
featured_image_id: article.featured_image_id,
view_count: article.view_count,
created_at: article.created_at,
updated_at: article.updated_at,
published_at: article.published_at,
}
}
}
fn slugify(title: &str) -> String {
let mut slug = String::new();
let mut last_was_dash = false;
for ch in title.chars().flat_map(char::to_lowercase) {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
last_was_dash = false;
} else if !last_was_dash && !slug.is_empty() {
slug.push('-');
last_was_dash = true;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
Uuid::new_v4().to_string()
} else {
slug
}
}
fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOffset>> {
published.then(|| Utc::now().into())
}
async fn find_article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Model> {
blog_articles::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
#[debug_handler]
async fn public_index(State(ctx): State<AppContext>) -> Result<Response> {
let articles = blog_articles::Entity::find()
.filter(blog_articles::Column::Published.eq(true))
.order_by_desc(blog_articles::Column::PublishedAt)
.all(&ctx.db)
.await?
.into_iter()
.map(ArticleResponse::from)
.collect();
format::json(ArticleListResponse { articles })
}
#[debug_handler]
async fn public_show(Path(slug): Path<String>, State(ctx): State<AppContext>) -> Result<Response> {
let article = blog_articles::Entity::find()
.filter(blog_articles::Column::Slug.eq(slug))
.filter(blog_articles::Column::Published.eq(true))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = article.into_active_model();
let next_count = active.view_count.as_ref().to_owned() + 1;
active.view_count = Set(next_count);
let article = active.update(&ctx.db).await?;
format::json(ArticleResponse::from(article))
}
#[debug_handler]
async fn admin_index(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let articles = blog_articles::Entity::find()
.order_by_desc(blog_articles::Column::CreatedAt)
.all(&ctx.db)
.await?
.into_iter()
.map(ArticleResponse::from)
.collect();
format::json(ArticleListResponse { articles })
}
#[debug_handler]
async fn admin_create(
auth: auth::JWT,
State(ctx): State<AppContext>,
Json(params): Json<ArticleParams>,
) -> Result<Response> {
let admin_user = admin::current_admin(auth, &ctx).await?;
let published = params.published.unwrap_or(false);
let article = blog_articles::ActiveModel {
id: Set(Uuid::new_v4()),
title: Set(params.title.clone()),
slug: Set(slugify(&params.title)),
content: Set(params.content),
excerpt: Set(params.excerpt),
published: Set(published),
author_id: Set(admin_user.id),
featured_image_id: Set(params.featured_image_id),
view_count: Set(0),
published_at: Set(published_at_for(published)),
..Default::default()
}
.insert(&ctx.db)
.await?;
format::json(ArticleResponse::from(article))
}
#[debug_handler]
async fn admin_update(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
Json(params): Json<ArticleParams>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let existing = find_article_by_id(&ctx, id).await?;
let was_published = existing.published;
let published = params.published.unwrap_or(was_published);
let mut article = existing.into_active_model();
article.title = Set(params.title.clone());
article.slug = Set(slugify(&params.title));
article.content = Set(params.content);
article.excerpt = Set(params.excerpt);
article.published = Set(published);
article.featured_image_id = Set(params.featured_image_id);
if published && !was_published {
article.published_at = Set(published_at_for(true));
} else if !published {
article.published_at = Set(None);
}
let article = article.update(&ctx.db).await?;
format::json(ArticleResponse::from(article))
}
#[debug_handler]
async fn admin_delete(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<AppContext>) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let article = find_article_by_id(&ctx, id).await?;
article.delete(&ctx.db).await?;
format::json(())
}
#[debug_handler]
async fn admin_publish(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<AppContext>) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let mut article = find_article_by_id(&ctx, id).await?.into_active_model();
article.published = Set(true);
article.published_at = Set(published_at_for(true));
let article = article.update(&ctx.db).await?;
format::json(ArticleResponse::from(article))
}
#[debug_handler]
async fn admin_unpublish(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<AppContext>) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let mut article = find_article_by_id(&ctx, id).await?.into_active_model();
article.published = Set(false);
article.published_at = Set(None);
let article = article.update(&ctx.db).await?;
format::json(ArticleResponse::from(article))
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api")
.add("/blog", get(public_index))
.add("/blog/{slug}", get(public_show))
.add("/admin/blog/articles", get(admin_index))
.add("/admin/blog/articles", post(admin_create))
.add("/admin/blog/articles/{id}", put(admin_update))
.add("/admin/blog/articles/{id}", delete(admin_delete))
.add("/admin/blog/articles/{id}/publish", post(admin_publish))
.add("/admin/blog/articles/{id}/unpublish", post(admin_unpublish))
}

344
src/controllers/frontend.rs Normal file
View File

@@ -0,0 +1,344 @@
use crate::{
controllers::{admin, auth as auth_controller},
models::{
_entities::{blog_articles, site_pages},
users::{self, LoginParams},
},
};
use chrono::Utc;
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
const ABOUT_SLUG: &str = "about";
#[derive(Debug, Deserialize)]
struct ArticleForm {
title: String,
content: String,
excerpt: Option<String>,
published: Option<String>,
featured_image_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AboutForm {
title: String,
content: String,
}
fn slugify(title: &str) -> String {
let mut slug = String::new();
let mut last_was_dash = false;
for ch in title.chars().flat_map(char::to_lowercase) {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
last_was_dash = false;
} else if !last_was_dash && !slug.is_empty() {
slug.push('-');
last_was_dash = true;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
Uuid::new_v4().to_string()
} else {
slug
}
}
fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOffset>> {
published.then(|| Utc::now().into())
}
fn is_checked(value: &Option<String>) -> bool {
value.as_deref().is_some_and(|value| value == "on" || value == "true")
}
fn normalize_empty(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() {
None
} else {
Some(value)
}
})
}
async fn about_page(ctx: &AppContext) -> Result<site_pages::Model> {
site_pages::Entity::find()
.filter(site_pages::Column::Slug.eq(ABOUT_SLUG))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
async fn article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Model> {
blog_articles::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
#[debug_handler]
async fn home(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let articles = blog_articles::Entity::find()
.filter(blog_articles::Column::Published.eq(true))
.order_by_desc(blog_articles::Column::PublishedAt)
.limit(5)
.all(&ctx.db)
.await?;
format::view(&v, "home/index.html", json!({ "articles": articles }))
}
#[debug_handler]
async fn about(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
format::view(&v, "pages/about.html", json!({ "page": about_page(&ctx).await? }))
}
#[debug_handler]
async fn blog_index(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let articles = blog_articles::Entity::find()
.filter(blog_articles::Column::Published.eq(true))
.order_by_desc(blog_articles::Column::PublishedAt)
.all(&ctx.db)
.await?;
format::view(&v, "blog/index.html", json!({ "articles": articles }))
}
#[debug_handler]
async fn blog_show(
ViewEngine(v): ViewEngine<TeraView>,
Path(slug): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let article = blog_articles::Entity::find()
.filter(blog_articles::Column::Slug.eq(slug))
.filter(blog_articles::Column::Published.eq(true))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = article.into_active_model();
let next_count = active.view_count.as_ref().to_owned() + 1;
active.view_count = Set(next_count);
let article = active.update(&ctx.db).await?;
format::view(&v, "blog/show.html", json!({ "article": article }))
}
#[debug_handler]
async fn admin_login_page(ViewEngine(v): ViewEngine<TeraView>) -> Result<Response> {
format::view(&v, "admin/login.html", json!({ "error": null }))
}
#[debug_handler]
async fn admin_login(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<LoginParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
return format::view(&v, "admin/login.html", json!({ "error": "Invalid credentials" }));
};
if !user.verify_password(&params.password) || !admin::is_admin(&ctx, &user) {
return format::view(&v, "admin/login.html", json!({ "error": "Invalid credentials" }));
}
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::render()
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
.redirect("/admin/dashboard")
}
#[debug_handler]
async fn admin_logout() -> Result<Response> {
format::render()
.cookies(&[auth_controller::clear_auth_cookie()])?
.redirect("/admin/login")
}
#[debug_handler]
async fn admin_home(
auth: auth::JWT,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let admin_user = admin::current_admin(auth, &ctx).await?;
format::view(&v, "admin/index.html", json!({ "admin": admin_user }))
}
#[debug_handler]
async fn admin_about(
auth: auth::JWT,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(&v, "admin/about.html", json!({ "page": about_page(&ctx).await? }))
}
#[debug_handler]
async fn admin_about_update(
auth: auth::JWT,
State(ctx): State<AppContext>,
Form(params): Form<AboutForm>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let mut page = about_page(&ctx).await?.into_active_model();
page.title = Set(params.title);
page.content = Set(params.content);
page.update(&ctx.db).await?;
format::redirect("/admin/about")
}
#[debug_handler]
async fn admin_articles(
auth: auth::JWT,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let articles = blog_articles::Entity::find()
.order_by_desc(blog_articles::Column::CreatedAt)
.all(&ctx.db)
.await?;
format::view(&v, "admin/blog/index.html", json!({ "articles": articles }))
}
#[debug_handler]
async fn admin_article_new(
auth: auth::JWT,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(&v, "admin/blog/new.html", json!({}))
}
#[debug_handler]
async fn admin_article_create(
auth: auth::JWT,
State(ctx): State<AppContext>,
Form(params): Form<ArticleForm>,
) -> Result<Response> {
let admin_user = admin::current_admin(auth, &ctx).await?;
let published = is_checked(&params.published);
blog_articles::ActiveModel {
id: Set(Uuid::new_v4()),
title: Set(params.title.clone()),
slug: Set(slugify(&params.title)),
content: Set(params.content),
excerpt: Set(normalize_empty(params.excerpt)),
published: Set(published),
author_id: Set(admin_user.id),
featured_image_id: Set(normalize_empty(params.featured_image_id)),
view_count: Set(0),
published_at: Set(published_at_for(published)),
..Default::default()
}
.insert(&ctx.db)
.await?;
format::redirect("/admin/blog/articles")
}
#[debug_handler]
async fn admin_article_edit(
auth: auth::JWT,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(
&v,
"admin/blog/edit.html",
json!({ "article": article_by_id(&ctx, id).await? }),
)
}
#[debug_handler]
async fn admin_article_update(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
Form(params): Form<ArticleForm>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let existing = article_by_id(&ctx, id).await?;
let was_published = existing.published;
let published = is_checked(&params.published);
let mut article = existing.into_active_model();
article.title = Set(params.title.clone());
article.slug = Set(slugify(&params.title));
article.content = Set(params.content);
article.excerpt = Set(normalize_empty(params.excerpt));
article.published = Set(published);
article.featured_image_id = Set(normalize_empty(params.featured_image_id));
if published && !was_published {
article.published_at = Set(published_at_for(true));
} else if !published {
article.published_at = Set(None);
}
article.update(&ctx.db).await?;
format::redirect("/admin/blog/articles")
}
#[debug_handler]
async fn admin_article_delete(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
article_by_id(&ctx, id).await?.delete(&ctx.db).await?;
format::redirect("/admin/blog/articles")
}
pub fn routes() -> Routes {
Routes::new()
.add("/", get(home))
.add("/about", get(about))
.add("/blog", get(blog_index))
.add("/blog/{slug}", get(blog_show))
.add("/admin/login", get(admin_login_page))
.add("/admin/login", post(admin_login))
.add("/admin/logout", post(admin_logout))
.add("/admin", get(admin_login_page))
.add("/admin/dashboard", get(admin_home))
.add("/admin/about", get(admin_about))
.add("/admin/about", post(admin_about_update))
.add("/admin/blog/articles", get(admin_articles))
.add("/admin/blog/articles/new", get(admin_article_new))
.add("/admin/blog/articles", post(admin_article_create))
.add("/admin/blog/articles/{id}/edit", get(admin_article_edit))
.add("/admin/blog/articles/{id}", post(admin_article_update))
.add("/admin/blog/articles/{id}/delete", post(admin_article_delete))
}

View File

@@ -1 +1,5 @@
pub mod admin;
pub mod auth;
pub mod blog;
pub mod frontend;
pub mod pages;

89
src/controllers/pages.rs Normal file
View File

@@ -0,0 +1,89 @@
use crate::{
controllers::admin,
models::_entities::site_pages,
};
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
const ABOUT_SLUG: &str = "about";
#[derive(Debug, Deserialize)]
struct AboutParams {
title: String,
content: String,
}
#[derive(Debug, Serialize)]
struct PageResponse {
id: Uuid,
slug: String,
title: String,
content: String,
updated_at: chrono::DateTime<chrono::FixedOffset>,
}
impl From<site_pages::Model> for PageResponse {
fn from(page: site_pages::Model) -> Self {
Self {
id: page.id,
slug: page.slug,
title: page.title,
content: page.content,
updated_at: page.updated_at,
}
}
}
async fn find_about(ctx: &AppContext) -> Result<site_pages::Model> {
site_pages::Entity::find()
.filter(site_pages::Column::Slug.eq(ABOUT_SLUG))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
#[debug_handler]
async fn about(State(ctx): State<AppContext>) -> Result<Response> {
format::json(PageResponse::from(find_about(&ctx).await?))
}
#[debug_handler]
async fn update_about(
auth: auth::JWT,
State(ctx): State<AppContext>,
Json(params): Json<AboutParams>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let page = match find_about(&ctx).await {
Ok(page) => {
let mut page = page.into_active_model();
page.title = Set(params.title);
page.content = Set(params.content);
page.update(&ctx.db).await?
}
Err(Error::NotFound) => {
site_pages::ActiveModel {
id: Set(Uuid::new_v4()),
slug: Set(ABOUT_SLUG.to_string()),
title: Set(params.title),
content: Set(params.content),
..Default::default()
}
.insert(&ctx.db)
.await?
}
Err(err) => return Err(err),
};
format::json(PageResponse::from(page))
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api")
.add("/about", get(about))
.add("/admin/about", put(update_about))
}

View File

@@ -9,7 +9,10 @@ use loco_rs::{
use tracing::info;
const I18N_DIR: &str = "assets/i18n";
const I18N_SHARED: &str = "assets/i18n/shared.ftl";
// Kept outside `I18N_DIR`: fluent-templates >=0.13 scans top-level *.ftl files
// in that dir as locales, and "shared" parses as a langid, so a shared.ftl
// living there would be loaded twice and fail with a duplicate-resource error.
const I18N_SHARED: &str = "assets/i18n_shared/shared.ftl";
#[allow(clippy::module_name_repetitions)]
pub struct ViewEngineInitializer;

View File

@@ -0,0 +1,51 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "audio_albums")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub title: String,
#[sea_orm(unique)]
pub slug: String,
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
pub cover_image_id: Option<String>,
pub artist: Option<String>,
pub release_date: Option<Date>,
pub published: bool,
pub uploader_id: i32,
pub view_count: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
pub published_at: Option<DateTimeWithTimeZone>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::audio_tracks::Entity")]
AudioTracks,
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UploaderId",
to = "super::users::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::audio_tracks::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioTracks.def()
}
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}

View File

@@ -0,0 +1,37 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "audio_tags")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
#[sea_orm(unique)]
pub name: String,
#[sea_orm(unique)]
pub slug: String,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::audio_track_tags::Entity")]
AudioTrackTags,
}
impl Related<super::audio_track_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioTrackTags.def()
}
}
impl Related<super::audio_tracks::Entity> for Entity {
fn to() -> RelationDef {
super::audio_track_tags::Relation::AudioTracks.def()
}
fn via() -> Option<RelationDef> {
Some(super::audio_track_tags::Relation::AudioTags.def().rev())
}
}

View File

@@ -0,0 +1,46 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "audio_track_tags")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub track_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub tag_id: Uuid,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::audio_tags::Entity",
from = "Column::TagId",
to = "super::audio_tags::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
AudioTags,
#[sea_orm(
belongs_to = "super::audio_tracks::Entity",
from = "Column::TrackId",
to = "super::audio_tracks::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
AudioTracks,
}
impl Related<super::audio_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioTags.def()
}
}
impl Related<super::audio_tracks::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioTracks.def()
}
}

View File

@@ -0,0 +1,56 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "audio_tracks")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub album_id: Uuid,
pub title: String,
pub slug: String,
pub audio_file_id: String,
pub track_number: Option<i32>,
pub duration: Option<i32>,
pub featured: bool,
pub play_count: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::audio_albums::Entity",
from = "Column::AlbumId",
to = "super::audio_albums::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
AudioAlbums,
#[sea_orm(has_many = "super::audio_track_tags::Entity")]
AudioTrackTags,
}
impl Related<super::audio_albums::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioAlbums.def()
}
}
impl Related<super::audio_track_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioTrackTags.def()
}
}
impl Related<super::audio_tags::Entity> for Entity {
fn to() -> RelationDef {
super::audio_track_tags::Relation::AudioTags.def()
}
fn via() -> Option<RelationDef> {
Some(super::audio_track_tags::Relation::AudioTracks.def().rev())
}
}

View File

@@ -0,0 +1,40 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "audit_logs")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub admin_user_id: i32,
pub action: String,
pub target_type: Option<String>,
pub target_id: Option<Uuid>,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub details: Option<Json>,
#[sea_orm(column_type = "custom(\"inet\")", nullable)]
pub ip_address: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub user_agent: Option<String>,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::AdminUserId",
to = "super::users::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}

View File

@@ -0,0 +1,42 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "blog_articles")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub title: String,
#[sea_orm(unique)]
pub slug: String,
#[sea_orm(column_type = "Text")]
pub content: String,
pub excerpt: Option<String>,
pub published: bool,
pub author_id: i32,
pub featured_image_id: Option<String>,
pub view_count: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
pub published_at: Option<DateTimeWithTimeZone>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::AuthorId",
to = "super::users::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}

View File

@@ -1,4 +1,12 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
pub mod prelude;
pub mod audio_albums;
pub mod audio_tags;
pub mod audio_track_tags;
pub mod audio_tracks;
pub mod audit_logs;
pub mod blog_articles;
pub mod site_pages;
pub mod users;

View File

@@ -1,2 +1,10 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
pub use super::audio_albums::Entity as AudioAlbums;
pub use super::audio_tags::Entity as AudioTags;
pub use super::audio_track_tags::Entity as AudioTrackTags;
pub use super::audio_tracks::Entity as AudioTracks;
pub use super::audit_logs::Entity as AuditLogs;
pub use super::blog_articles::Entity as BlogArticles;
pub use super::site_pages::Entity as SitePages;
pub use super::users::Entity as Users;

View File

@@ -0,0 +1,21 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "site_pages")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
#[sea_orm(unique)]
pub slug: String,
pub title: String,
#[sea_orm(column_type = "Text")]
pub content: String,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

View File

@@ -1,4 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
@@ -24,7 +24,33 @@ pub struct Model {
pub email_verified_at: Option<DateTimeWithTimeZone>,
pub magic_link_token: Option<String>,
pub magic_link_expiration: Option<DateTimeWithTimeZone>,
pub theme: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
pub enum Relation {
#[sea_orm(has_many = "super::audio_albums::Entity")]
AudioAlbums,
#[sea_orm(has_many = "super::audit_logs::Entity")]
AuditLogs,
#[sea_orm(has_many = "super::blog_articles::Entity")]
BlogArticles,
}
impl Related<super::audio_albums::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioAlbums.def()
}
}
impl Related<super::audit_logs::Entity> for Entity {
fn to() -> RelationDef {
Relation::AuditLogs.def()
}
}
impl Related<super::blog_articles::Entity> for Entity {
fn to() -> RelationDef {
Relation::BlogArticles.def()
}
}

View File

@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
pub use super::_entities::audio_albums::{ActiveModel, Model, Entity};
pub type AudioAlbums = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
if !insert && self.updated_at.is_unchanged() {
let mut this = self;
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
Ok(this)
} else {
Ok(self)
}
}
}
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}

22
src/models/audio_tags.rs Normal file
View File

@@ -0,0 +1,22 @@
use sea_orm::entity::prelude::*;
pub use super::_entities::audio_tags::{ActiveModel, Model, Entity};
pub type AudioTags = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, _insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
Ok(self)
}
}
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}

View File

@@ -0,0 +1,22 @@
use sea_orm::entity::prelude::*;
pub use super::_entities::audio_track_tags::{ActiveModel, Model, Entity};
pub type AudioTrackTags = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, _insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
Ok(self)
}
}
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}

View File

@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
pub use super::_entities::audio_tracks::{ActiveModel, Model, Entity};
pub type AudioTracks = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
if !insert && self.updated_at.is_unchanged() {
let mut this = self;
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
Ok(this)
} else {
Ok(self)
}
}
}
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}

22
src/models/audit_logs.rs Normal file
View File

@@ -0,0 +1,22 @@
use sea_orm::entity::prelude::*;
pub use super::_entities::audit_logs::{ActiveModel, Model, Entity};
pub type AuditLogs = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, _insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
Ok(self)
}
}
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}

View File

@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
pub use super::_entities::blog_articles::{ActiveModel, Model, Entity};
pub type BlogArticles = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
if !insert && self.updated_at.is_unchanged() {
let mut this = self;
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
Ok(this)
} else {
Ok(self)
}
}
}
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}

View File

@@ -1,2 +1,9 @@
pub mod _entities;
pub mod users;
pub mod audio_tags;
pub mod audio_tracks;
pub mod audio_track_tags;
pub mod audit_logs;
pub mod blog_articles;
pub mod audio_albums;
pub mod site_pages;

23
src/models/site_pages.rs Normal file
View File

@@ -0,0 +1,23 @@
use sea_orm::entity::prelude::*;
pub use super::_entities::site_pages::{ActiveModel, Entity, Model};
pub type SitePages = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
if !insert && self.updated_at.is_unchanged() {
let mut this = self;
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
Ok(this)
} else {
Ok(self)
}
}
}
impl Model {}
impl ActiveModel {}
impl Entity {}

View File

@@ -8,16 +8,18 @@ pub struct LoginResponse {
pub pid: String,
pub name: String,
pub is_verified: bool,
pub is_admin: bool,
}
impl LoginResponse {
#[must_use]
pub fn new(user: &users::Model, token: &String) -> Self {
pub fn new(user: &users::Model, token: &String, is_admin: bool) -> Self {
Self {
token: token.to_string(),
pid: user.pid.to_string(),
name: user.name.clone(),
is_verified: user.email_verified_at.is_some(),
is_admin,
}
}
}
@@ -27,15 +29,17 @@ pub struct CurrentResponse {
pub pid: String,
pub name: String,
pub email: String,
pub is_admin: bool,
}
impl CurrentResponse {
#[must_use]
pub fn new(user: &users::Model) -> Self {
pub fn new(user: &users::Model, is_admin: bool) -> Self {
Self {
pid: user.pid.to_string(),
name: user.name.clone(),
email: user.email.clone(),
is_admin,
}
}
}