8 Commits

Author SHA1 Message Date
Priec
a169999ff1 fixed title
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-20 20:42:51 +02:00
Priec
8b175557dc fixing ceo n performance 2026-05-20 20:42:26 +02:00
5e31f00b77 fixing name
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-20 12:15:37 +00:00
8f89423994 prod setup
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-20 11:01:23 +00:00
Priec
f92cb1f134 caddyfile
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-20 11:24:06 +02:00
Priec
a385b0540d prod setup is now fully ready 2026-05-20 11:14:42 +02:00
Priec
8236fb83bc home page removed slogan
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-19 23:47:52 +02:00
Priec
78cef07ed9 home page improved 2026-05-19 23:41:34 +02:00
59 changed files with 572 additions and 125 deletions

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
target
node_modules
Dockerfile
.dockerignore
docker-compose.prod.yml
Makefile
Caddyfile
.git
.gitignore
.env
.env.*
*.sqlite
*.sqlite-*
uploads
*report.html

26
.env.production.example Normal file
View File

@@ -0,0 +1,26 @@
CONTAINER_NAME=universal-web
REVERSE_PROXY_NETWORK=
UPLOADS_VOLUME_NAME=universal_web_uploads
APP_HOST=https://gitara.farmeris.sk
PORT=5150
SERVER_BINDING=0.0.0.0
DATABASE_URL=
JWT_SECRET=
ADMIN_EMAIL=
ADMIN_PASSWORD=
ADMIN_NAME=Admin
UPLOADS_ROOT=data/uploads
LOG_LEVEL=info
LOG_FORMAT=compact
MAILER_STUB=true
SMTP_ENABLE=false
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_USER=
SMTP_PASSWORD=

2
.gitignore vendored
View File

@@ -1,6 +1,5 @@
**/config/local.yaml
**/config/*.local.yaml
**/config/production.yaml
# Generated by Cargo
# will have compiled files and executables
@@ -22,3 +21,4 @@ target/
.env.production
uploads/
*.report.html
favicon_io.zip

14
Caddyfile Normal file
View File

@@ -0,0 +1,14 @@
gitara.farmeris.sk {
encode gzip
@static path /static/*
header @static Cache-Control "public, max-age=2592000"
rewrite /favicon.ico /static/favicon/favicon.ico
reverse_proxy gitara-web:5150
}
www.gitara.farmeris.sk {
redir https://gitara.farmeris.sk{uri} permanent
}

60
Cargo.lock generated
View File

@@ -1523,6 +1523,36 @@ dependencies = [
"wasip3",
]
[[package]]
name = "gitara_web"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"axum-extra",
"bytes",
"chrono",
"dotenvy",
"fluent-templates",
"include_dir",
"insta",
"loco-rs",
"migration",
"regex",
"rstest",
"sea-orm",
"serde",
"serde_json",
"serial_test",
"time",
"tokio",
"tracing",
"tracing-subscriber",
"unic-langid",
"uuid",
"validator",
]
[[package]]
name = "glob"
version = "0.3.3"
@@ -5052,36 +5082,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal_web"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"axum-extra",
"bytes",
"chrono",
"dotenvy",
"fluent-templates",
"include_dir",
"insta",
"loco-rs",
"migration",
"regex",
"rstest",
"sea-orm",
"serde",
"serde_json",
"serial_test",
"time",
"tokio",
"tracing",
"tracing-subscriber",
"unic-langid",
"uuid",
"validator",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"

View File

@@ -1,11 +1,11 @@
[workspace]
[package]
name = "universal_web"
name = "gitara_web"
version = "0.1.0"
edition = "2021"
publish = false
default-run = "universal_web-cli"
default-run = "gitara_web-cli"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -45,7 +45,7 @@ axum-extra = { version = "0.10", features = ["form"] }
bytes = { version = "1" }
[[bin]]
name = "universal_web-cli"
name = "gitara_web-cli"
path = "src/bin/main.rs"
required-features = []

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM rust:1-slim-bookworm AS builder
WORKDIR /usr/src
COPY . .
RUN cargo build --release --bin gitara_web-cli
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /usr/app
COPY --from=builder /usr/src/assets assets
COPY --from=builder /usr/src/config config
COPY --from=builder /usr/src/target/release/gitara_web-cli gitara_web-cli
ENV LOCO_ENV=production
EXPOSE 5150
ENTRYPOINT ["/usr/app/gitara_web-cli"]
CMD ["start"]

20
Makefile Normal file
View File

@@ -0,0 +1,20 @@
COMPOSE = docker compose -f docker-compose.prod.yml --env-file .env.production
.PHONY: up down restart logs build ps
up:
$(COMPOSE) up -d --build
down:
$(COMPOSE) down
restart: down up
logs:
$(COMPOSE) logs -f --tail=100
build:
$(COMPOSE) build --no-cache
ps:
$(COMPOSE) ps

View File

@@ -1,4 +1,4 @@
brand = Universal Web
brand = My guitar
hello-world = Hello world!
meta-description = A guitar player's personal site. News, blog posts, albums, and songs in one place.
nav-home = Home
@@ -31,8 +31,7 @@ home-title = Home
home-sub = news and updates.
home-all-posts = All posts
home-recent = Recent posts
home-tagline = guitar player - original songs, albums, and notes
home-sections = about/ blog/ audio/ songs/
home-picks = have a listen
home-no-posts = no published posts yet
blog-title = Blog
blog-sub = published article(s)

View File

@@ -1,4 +1,4 @@
brand = Universal Web
brand = My guitar
hello-world = Hello world!
meta-description = A guitar player's personal site. News, blog posts, albums, and songs in one place.
nav-home = Home
@@ -31,8 +31,7 @@ home-title = Home
home-sub = news and updates.
home-all-posts = All posts
home-recent = Recent posts
home-tagline = guitar player - original songs, albums, and notes
home-sections = about/ blog/ audio/ songs/
home-picks = have a listen
home-no-posts = no published posts yet
blog-title = Blog
blog-sub = published article(s)

View File

@@ -1,4 +1,4 @@
brand = Universal Web
brand = Moja gitara
hello-world = Ahoj svet!
meta-description = Osobná stránka gitaristu. Novinky, blog, albumy a skladby na jednom mieste.
nav-home = Domov
@@ -31,8 +31,7 @@ home-title = Domov
home-sub = novinky a aktuality.
home-all-posts = Všetky príspevky
home-recent = Posledné príspevky
home-tagline = gitarista - autorské skladby, albumy a poznámky
home-sections = about/ blog/ audio/ songs/
home-picks = vypočuj si
home-no-posts = zatiaľ žiadne zverejnené príspevky
blog-title = Blog
blog-sub = zverejnené články

View File

@@ -142,7 +142,7 @@ body {
.t-green { color: oklch(var(--su)); }
.t-yellow { color: oklch(var(--wa)); }
.t-red { color: oklch(var(--er)); }
.t-dim { color: oklch(var(--bc) / 0.5); }
.t-dim { color: oklch(var(--bc) / 0.75); }
/* --- window titlebar (the header) -------------------------- */
.term-titlebar {
@@ -230,7 +230,7 @@ body {
line-height: 1.15;
color: oklch(var(--p));
}
.term-sub { margin-top: 0.2rem; font-size: 0.85rem; color: oklch(var(--bc) / 0.55); }
.term-sub { margin-top: 0.2rem; font-size: 0.85rem; color: oklch(var(--bc) / 0.8); }
.term-cmd-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
/* --- responsive card grid ---------------------------------- */

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/static/favicon/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/static/favicon/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,12 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock title %}</title>
<meta name="description" content="{% block meta_description %}{{ t(key="meta-description", lang=lang | default(value='sk')) }}{% endblock meta_description %}">
<link rel="icon" type="image/x-icon" href="/static/favicon/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png">
<link rel="manifest" href="/static/favicon/site.webmanifest">
<script>
function applyTheme(t) {
var dark = t === 'dark'
@@ -39,7 +45,7 @@
<link href="/static/css/app.css" rel="stylesheet" type="text/css">
{% block head %}{% endblock head %}
<link href="/static/css/theme.css" rel="stylesheet" type="text/css">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script src="/static/vendor/htmx/htmx-1.9.12.min.js"></script>
<style>
@media (min-width: 768px) {
.nav-menu { flex-direction: row; }

View File

@@ -4,6 +4,12 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ t(key="brand", lang=lang | default(value='sk')) }}{% endblock title %}</title>
<meta name="description" content="{% block meta_description %}{{ t(key="meta-description", lang=lang | default(value='sk')) }}{% endblock meta_description %}">
<link rel="icon" type="image/x-icon" href="/static/favicon/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png">
<link rel="manifest" href="/static/favicon/site.webmanifest">
<script>
function applyTheme(t) {
var dark = t === 'dark'
@@ -222,7 +228,7 @@
</script>
<link href="/static/css/app.css" rel="stylesheet" type="text/css">
<link href="/static/css/theme.css" rel="stylesheet" type="text/css">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script src="/static/vendor/htmx/htmx-1.9.12.min.js"></script>
<style>
@media (min-width: 768px) {
.nav-menu { flex-direction: row; }

View File

@@ -15,10 +15,53 @@
</div>
</header>
<div class="term-screen mb-6">
<p class="line out">→ {{ t(key="home-tagline", lang=lang | default(value='sk')) }}</p>
<p class="line out">{{ t(key="home-sections", lang=lang | default(value='sk')) }}</p>
</div>
{% if featured_track or featured_album %}
<section class="mb-8">
<p class="term-cmd-line mb-6"><span class="t-dim"># </span>{{ t(key="home-picks", lang=lang | default(value='sk')) }}</p>
<div class="term-grid">
{% if featured_track %}
<article class="card">
<div class="term-head">
<span class="term-head-name">~/audio/tracks/{{ featured_track.slug }}</span>
<span class="term-head-meta term-tag is-green">{{ t(key="song", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
<div class="term-track">
<button type="button" class="uw-play btn btn-primary btn-sm"
data-src="/audio/tracks/{{ featured_track.id }}/stream" data-title="{{ featured_track.title }}">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
<span class="term-track-name"><span class="t-green"></span> {{ featured_track.title }}</span>
</div>
</div>
</article>
{% endif %}
{% if featured_album %}
<article class="card">
<div class="term-head">
<span class="term-head-name">~/audio/{{ featured_album.slug }}/</span>
<span class="term-head-meta term-tag is-purple">{{ t(key="album", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
{% if featured_album.cover_image_id %}
<img src="/images/{{ featured_album.cover_image_id }}" alt="" class="mb-3">
{% endif %}
<h2 class="card-title text-base">{{ featured_album.title }}</h2>
{% if featured_album.artist %}
<p class="text-sm t-aqua">{{ featured_album.artist }}</p>
{% endif %}
{% if featured_album.description %}
<p class="term-prose text-sm opacity-80">{{ featured_album.description }}</p>
{% endif %}
<div class="flex flex-wrap gap-2 pt-2">
<button type="button" class="uw-play-album-remote btn btn-primary btn-sm"
data-album-tracks-url="/audio/albums/{{ featured_album.slug }}/tracks">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
<a href="/audio/albums/{{ featured_album.slug }}" class="btn btn-outline btn-sm">{{ t(key="audio-open", lang=lang | default(value='sk')) }}</a>
</div>
</div>
</article>
{% endif %}
</div>
</section>
{% endif %}
<section>
<p class="term-cmd-line mb-6"><span class="t-dim"># </span>{{ t(key="home-recent", lang=lang | default(value='sk')) }} <span class="t-dim">({{ articles | length }})</span></p>
@@ -61,10 +104,53 @@
</div>
</header>
<div class="term-screen mb-6">
<p class="line">{{ t(key="home-tagline", lang=lang | default(value='sk')) }}</p>
<p class="line out">{{ t(key="home-sections", lang=lang | default(value='sk')) }}</p>
</div>
{% if featured_track or featured_album %}
<section class="mb-8">
<p class="term-cmd-line mb-6"><span class="t-dim"># </span>{{ t(key="home-picks", lang=lang | default(value='sk')) }}</p>
<div class="term-grid">
{% if featured_track %}
<article class="card">
<div class="term-head">
<span class="term-head-name">~/audio/tracks/{{ featured_track.slug }}</span>
<span class="term-head-meta term-tag is-green">{{ t(key="song", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
<div class="term-track">
<button type="button" class="uw-play btn btn-primary btn-sm"
data-src="/audio/tracks/{{ featured_track.id }}/stream" data-title="{{ featured_track.title }}">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
<span class="term-track-name"><span class="t-green"></span> {{ featured_track.title }}</span>
</div>
</div>
</article>
{% endif %}
{% if featured_album %}
<article class="card">
<div class="term-head">
<span class="term-head-name">~/audio/{{ featured_album.slug }}/</span>
<span class="term-head-meta term-tag is-purple">{{ t(key="album", lang=lang | default(value='sk')) }}</span>
</div>
<div class="card-body">
{% if featured_album.cover_image_id %}
<img src="/images/{{ featured_album.cover_image_id }}" alt="" class="mb-3">
{% endif %}
<h2 class="card-title text-base">{{ featured_album.title }}</h2>
{% if featured_album.artist %}
<p class="text-sm t-aqua">{{ featured_album.artist }}</p>
{% endif %}
{% if featured_album.description %}
<p class="term-prose text-sm opacity-80">{{ featured_album.description }}</p>
{% endif %}
<div class="flex flex-wrap gap-2 pt-2">
<button type="button" class="uw-play-album-remote btn btn-primary btn-sm"
data-album-tracks-url="/audio/albums/{{ featured_album.slug }}/tracks">{{ t(key="audio-play", lang=lang | default(value='sk')) }}</button>
<a href="/audio/albums/{{ featured_album.slug }}" class="btn btn-outline btn-sm">{{ t(key="audio-open", lang=lang | default(value='sk')) }}</a>
</div>
</div>
</article>
{% endif %}
</div>
</section>
{% endif %}
<section>
<p class="term-cmd-line mb-6"><span class="t-dim"># </span>{{ t(key="home-recent", lang=lang | default(value='sk')) }} <span class="t-dim">({{ articles | length }})</span></p>

View File

@@ -71,7 +71,7 @@ mailer:
# Database Configuration
database:
# Database connection URI
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/universal_web_development") }}
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/gitara_web_development") }}
# When enabled, the sql query will be logged.
enable_logging: false
# Set the timeout duration when acquiring a connection.

57
config/production.yaml Normal file
View File

@@ -0,0 +1,57 @@
logger:
enable: true
pretty_backtrace: false
level: "{{ get_env(name="LOG_LEVEL", default="info") }}"
format: "{{ get_env(name="LOG_FORMAT", default="compact") }}"
server:
port: {{ get_env(name="PORT", default="5150") }}
binding: "{{ get_env(name="SERVER_BINDING", default="0.0.0.0") }}"
host: "{{ get_env(name="APP_HOST") }}"
middlewares:
static:
enable: true
must_exist: true
precompressed: false
folder:
uri: "/static"
path: "assets/static"
fallback: "assets/static/404.html"
workers:
mode: "{{ get_env(name="WORKER_MODE", default="BackgroundAsync") }}"
mailer:
stub: {{ get_env(name="MAILER_STUB", default="true") }}
smtp:
enable: {{ get_env(name="SMTP_ENABLE", default="false") }}
host: "{{ get_env(name="SMTP_HOST", default="localhost") }}"
port: {{ get_env(name="SMTP_PORT", default="1025") }}
secure: {{ get_env(name="SMTP_SECURE", default="false") }}
auth:
user: "{{ get_env(name="SMTP_USER", default="") }}"
password: "{{ get_env(name="SMTP_PASSWORD", default="") }}"
database:
uri: "{{ get_env(name="DATABASE_URL") }}"
enable_logging: {{ get_env(name="DB_ENABLE_LOGGING", default="false") }}
connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }}
idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }}
min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }}
max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="5") }}
auto_migrate: {{ get_env(name="DB_AUTO_MIGRATE", default="true") }}
dangerously_truncate: false
dangerously_recreate: false
auth:
jwt:
location:
- from: Cookie
name: auth_token
- from: Bearer
secret: "{{ get_env(name="JWT_SECRET") }}"
expiration: {{ get_env(name="JWT_EXPIRATION", default="604800") }}
settings:
admin_email: "{{ get_env(name="ADMIN_EMAIL", default="") }}"
uploads_root: "{{ get_env(name="UPLOADS_ROOT", default="data/uploads") }}"

View File

@@ -68,7 +68,7 @@ mailer:
# Database Configuration
database:
# Database connection URI
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/universal_web_test") }}
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/gitara_web_test") }}
# When enabled, the sql query will be logged.
enable_logging: false
# Set the timeout duration when acquiring a connection.

27
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,27 @@
services:
universal-web:
container_name: gitara-web
build:
context: .
dockerfile: Dockerfile
env_file:
- .env.production
volumes:
- gitara_web_data:/usr/app/data
networks:
- gitara-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:5150/_ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
networks:
gitara-net:
external: true
volumes:
gitara_web_data:
name: gitara_web_data

View File

@@ -1,6 +1,6 @@
#[allow(unused_imports)]
use loco_rs::{cli::playground, prelude::*};
use universal_web::app::App;
use gitara_web::app::App;
#[tokio::main]
async fn main() -> loco_rs::Result<()> {

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
favicon/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

BIN
favicon/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
favicon/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
favicon/site.webmanifest Normal file
View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@@ -33,8 +33,17 @@ impl MigrationTrait for Migration {
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::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(BlogArticles::Title)
.string_len(500)
.not_null(),
)
.col(
ColumnDef::new(BlogArticles::Slug)
.string_len(500)
@@ -42,7 +51,11 @@ impl MigrationTrait for Migration {
.unique_key(),
)
.col(ColumnDef::new(BlogArticles::Content).text().not_null())
.col(ColumnDef::new(BlogArticles::Excerpt).string_len(1000).null())
.col(
ColumnDef::new(BlogArticles::Excerpt)
.string_len(1000)
.null(),
)
.col(
ColumnDef::new(BlogArticles::Published)
.boolean()

View File

@@ -30,7 +30,12 @@ impl MigrationTrait for Migration {
Table::create()
.table(AuditLogs::Table)
.if_not_exists()
.col(ColumnDef::new(AuditLogs::Id).uuid().not_null().primary_key())
.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())

View File

@@ -34,8 +34,17 @@ impl MigrationTrait for Migration {
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::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(AudioAlbums::Title)
.string_len(500)
.not_null(),
)
.col(
ColumnDef::new(AudioAlbums::Slug)
.string_len(500)

View File

@@ -32,9 +32,18 @@ impl MigrationTrait for Migration {
Table::create()
.table(AudioTracks::Table)
.if_not_exists()
.col(ColumnDef::new(AudioTracks::Id).uuid().not_null().primary_key())
.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::Title)
.string_len(500)
.not_null(),
)
.col(ColumnDef::new(AudioTracks::Slug).string_len(500).not_null())
.col(
ColumnDef::new(AudioTracks::AudioFileId)

View File

@@ -19,7 +19,12 @@ impl MigrationTrait for Migration {
Table::create()
.table(AudioTags::Table)
.if_not_exists()
.col(ColumnDef::new(AudioTags::Id).uuid().not_null().primary_key())
.col(
ColumnDef::new(AudioTags::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(AudioTags::Name)
.string_len(100)

View File

@@ -21,7 +21,12 @@ impl MigrationTrait for Migration {
Table::create()
.table(SitePages::Table)
.if_not_exists()
.col(ColumnDef::new(SitePages::Id).uuid().not_null().primary_key())
.col(
ColumnDef::new(SitePages::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(SitePages::Slug)
.string_len(100)

View File

@@ -1,6 +1,6 @@
use loco_rs::cli;
use migration::Migrator;
use universal_web::app::App;
use gitara_web::app::App;
#[tokio::main]
async fn main() -> loco_rs::Result<()> {

View File

@@ -1,7 +1,4 @@
use crate::{
controllers::admin,
models::_entities::blog_articles,
};
use crate::{controllers::admin, models::_entities::blog_articles};
use chrono::Utc;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
@@ -195,7 +192,11 @@ async fn admin_update(
}
#[debug_handler]
async fn admin_delete(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<AppContext>) -> Result<Response> {
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?;
@@ -203,7 +204,11 @@ async fn admin_delete(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<A
}
#[debug_handler]
async fn admin_publish(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<AppContext>) -> Result<Response> {
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);
@@ -213,7 +218,11 @@ async fn admin_publish(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<
}
#[debug_handler]
async fn admin_unpublish(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<AppContext>) -> Result<Response> {
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);

View File

@@ -1,7 +1,7 @@
use crate::{
controllers::{admin, auth as auth_controller, i18n::current_lang},
models::{
_entities::{blog_articles, site_pages},
_entities::{audio_albums, audio_tracks, blog_articles, site_pages},
users::{self, LoginParams},
},
};
@@ -9,7 +9,8 @@ use axum_extra::extract::cookie::CookieJar;
use chrono::Utc;
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
sea_query::Expr, ActiveModelTrait, ColumnTrait, EntityTrait, Order, QueryFilter, QueryOrder,
QuerySelect, Set,
};
use serde::Deserialize;
use serde_json::json;
@@ -59,7 +60,9 @@ fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOff
}
fn is_checked(value: &Option<String>) -> bool {
value.as_deref().is_some_and(|value| value == "on" || value == "true")
value
.as_deref()
.is_some_and(|value| value == "on" || value == "true")
}
fn normalize_empty(value: Option<String>) -> Option<String> {
@@ -119,11 +122,31 @@ async fn home(
.all(&ctx.db)
.await?;
// A random published song to suggest on the landing page.
let featured_track = audio_tracks::Entity::find()
.filter(audio_tracks::Column::Published.eq(true))
.order_by(Expr::cust("RANDOM()"), Order::Asc)
.one(&ctx.db)
.await?;
// A random published album, never the one the suggested song belongs to.
let mut album_query =
audio_albums::Entity::find().filter(audio_albums::Column::Published.eq(true));
if let Some(album_id) = featured_track.as_ref().and_then(|track| track.album_id) {
album_query = album_query.filter(audio_albums::Column::Id.ne(album_id));
}
let featured_album = album_query
.order_by(Expr::cust("RANDOM()"), Order::Asc)
.one(&ctx.db)
.await?;
format::view(
&v,
"home/index.html",
json!({
"articles": articles,
"featured_track": featured_track,
"featured_album": featured_album,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
@@ -340,7 +363,11 @@ async fn admin_article_new(
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(&v, "admin/blog/new.html", json!({ "lang": current_lang(&jar) }))
format::view(
&v,
"admin/blog/new.html",
json!({ "lang": current_lang(&jar) }),
)
}
#[debug_handler]
@@ -445,5 +472,8 @@ pub fn routes() -> Routes {
.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))
.add(
"/admin/blog/articles/{id}/delete",
post(admin_article_delete),
)
}

View File

@@ -13,7 +13,10 @@ pub struct LangForm {
}
pub fn current_lang(jar: &axum_extra::extract::cookie::CookieJar) -> String {
match jar.get(LANG_COOKIE).map(|cookie| cookie.value().to_string()) {
match jar
.get(LANG_COOKIE)
.map(|cookie| cookie.value().to_string())
{
Some(ref lang) if lang == "en" => "en".to_string(),
_ => "sk".to_string(),
}

View File

@@ -215,7 +215,9 @@ async fn read_multipart_file(mut multipart: Multipart, max_bytes: usize) -> Resu
return Ok(data);
}
Err(Error::BadRequest("multipart field `file` is required".to_string()))
Err(Error::BadRequest(
"multipart field `file` is required".to_string(),
))
}
async fn read_track_upload(
@@ -254,7 +256,11 @@ async fn read_track_upload(
match name.as_str() {
"title" => title = normalize_empty(Some(value)),
"track_number" => {
track_number = value.trim().parse::<i32>().ok().filter(|number| *number > 0)
track_number = value
.trim()
.parse::<i32>()
.ok()
.filter(|number| *number > 0)
}
"featured" => featured = value == "on" || value == "true" || value == "1",
"published" => published = value == "on" || value == "true" || value == "1",
@@ -263,7 +269,8 @@ async fn read_track_upload(
}
}
let data = data.ok_or_else(|| Error::BadRequest("multipart field `file` is required".to_string()))?;
let data =
data.ok_or_else(|| Error::BadRequest("multipart field `file` is required".to_string()))?;
if data.is_empty() {
return Err(Error::BadRequest("empty file upload".to_string()));
}
@@ -356,7 +363,11 @@ async fn unique_album_slug(ctx: &AppContext, title: &str) -> Result<String> {
Ok(slug)
}
async fn unique_track_slug(ctx: &AppContext, album_id: Option<Uuid>, title: &str) -> Result<String> {
async fn unique_track_slug(
ctx: &AppContext,
album_id: Option<Uuid>,
title: &str,
) -> Result<String> {
let base = slugify(title);
let mut slug = base.clone();
let mut suffix = 2;
@@ -411,7 +422,12 @@ async fn track_by_id(ctx: &AppContext, id: Uuid) -> Result<audio_tracks::Model>
.ok_or_else(|| Error::NotFound)
}
async fn store_upload(ctx: &AppContext, folder: &str, extension: &str, data: Vec<u8>) -> Result<String> {
async fn store_upload(
ctx: &AppContext,
folder: &str,
extension: &str,
data: Vec<u8>,
) -> Result<String> {
let filename = format!("{}.{}", Uuid::new_v4(), extension);
let key = format!("{folder}/{filename}");
ctx.storage
@@ -421,7 +437,11 @@ async fn store_upload(ctx: &AppContext, folder: &str, extension: &str, data: Vec
}
#[debug_handler]
async fn image_upload(auth: auth::JWT, State(ctx): State<AppContext>, multipart: Multipart) -> Result<Response> {
async fn image_upload(
auth: auth::JWT,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let data = read_multipart_file(multipart, IMAGE_MAX_BYTES).await?;
let extension = detect_image_extension(&data)?;
@@ -436,7 +456,10 @@ async fn image_upload(auth: auth::JWT, State(ctx): State<AppContext>, multipart:
}
#[debug_handler]
async fn image_serve(Path(filename): Path<String>, State(ctx): State<AppContext>) -> Result<Response> {
async fn image_serve(
Path(filename): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let filename = safe_filename(&filename)?;
let extension = filename.rsplit('.').next().unwrap_or("");
let key = format!("{IMAGE_STORAGE_DIR}/{filename}");
@@ -449,7 +472,11 @@ async fn image_serve(Path(filename): Path<String>, State(ctx): State<AppContext>
}
#[debug_handler]
async fn audio_upload(auth: auth::JWT, State(ctx): State<AppContext>, multipart: Multipart) -> Result<Response> {
async fn audio_upload(
auth: auth::JWT,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let data = read_multipart_file(multipart, AUDIO_MAX_BYTES).await?;
let extension = detect_audio_extension(&data)?;
@@ -758,7 +785,9 @@ async fn admin_album_add_track(
let track = track_by_id(&ctx, params.track_id).await?;
if track.album_id.is_some() {
return Err(Error::BadRequest("song already belongs to an album".to_string()));
return Err(Error::BadRequest(
"song already belongs to an album".to_string(),
));
}
let mut active = track.into_active_model();
@@ -828,7 +857,11 @@ async fn create_uploaded_track(
let (data, title, track_number, featured, published) = read_track_upload(multipart).await?;
let extension = detect_audio_extension(&data)?;
let filename = store_upload(ctx, AUDIO_STORAGE_DIR, extension, data).await?;
let title = title.unwrap_or_else(|| filename.trim_end_matches(&format!(".{extension}")).to_string());
let title = title.unwrap_or_else(|| {
filename
.trim_end_matches(&format!(".{extension}"))
.to_string()
});
audio_tracks::ActiveModel {
id: Set(Uuid::new_v4()),
@@ -886,7 +919,10 @@ async fn admin_track_delete(
let album_id = track.album_id;
let _ = ctx
.storage
.delete(StdPath::new(&format!("{AUDIO_STORAGE_DIR}/{}", track.audio_file_id)))
.delete(StdPath::new(&format!(
"{AUDIO_STORAGE_DIR}/{}",
track.audio_file_id
)))
.await;
track.delete(&ctx.db).await?;
if let Some(album_id) = album_id {
@@ -938,10 +974,16 @@ async fn admin_track_unpublish(
}
}
async fn stream_audio_file(config: &Config, filename: &str, headers: &HeaderMap) -> Result<Response> {
async fn stream_audio_file(
config: &Config,
filename: &str,
headers: &HeaderMap,
) -> Result<Response> {
let filename = safe_filename(filename)?;
let path = uploads_root(config)?.join(AUDIO_STORAGE_DIR).join(filename);
let mut file = tokio::fs::File::open(&path).await.map_err(|_| Error::NotFound)?;
let mut file = tokio::fs::File::open(&path)
.await
.map_err(|_| Error::NotFound)?;
let total_len = file.metadata().await?.len();
let extension = filename.rsplit('.').next().unwrap_or("mp3");
let content_type = audio_content_type(extension);
@@ -989,7 +1031,8 @@ fn parse_range(headers: &HeaderMap, total_len: u64) -> Result<(StatusCode, u64,
let suffix_range = start.is_empty();
let start = if suffix_range {
let suffix = u64::from_str(end).map_err(|_| Error::BadRequest("invalid range header".to_string()))?;
let suffix = u64::from_str(end)
.map_err(|_| Error::BadRequest("invalid range header".to_string()))?;
total_len.saturating_sub(suffix)
} else {
u64::from_str(start).map_err(|_| Error::BadRequest("invalid range header".to_string()))?
@@ -1039,9 +1082,15 @@ async fn track_stream(
pub fn routes() -> Routes {
Routes::new()
.add("/images/upload", post(image_upload).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)))
.add(
"/images/upload",
post(image_upload).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)),
)
.add("/images/{filename}", get(image_serve))
.add("/audio/upload", post(audio_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)))
.add(
"/audio/upload",
post(audio_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)),
)
.add("/audio/stream/{filename}", get(raw_audio_stream))
.add("/audio/albums", get(public_albums))
.add("/audio/albums/{slug}", get(public_album))
@@ -1050,16 +1099,43 @@ pub fn routes() -> Routes {
.add("/audio/tracks/{id}/stream", get(track_stream))
.add("/admin/audio/albums", get(admin_albums))
.add("/admin/audio/albums/create", get(admin_album_new))
.add("/admin/audio/albums/create", post(admin_album_create).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)))
.add(
"/admin/audio/albums/create",
post(admin_album_create).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)),
)
.add("/admin/audio/tracks", get(admin_tracks))
.add("/admin/audio/tracks/upload", get(admin_song_upload_form))
.add("/admin/audio/tracks/upload-file", post(admin_song_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)))
.add("/admin/audio/albums/{album_id}/tracks", get(admin_album_tracks))
.add("/admin/audio/albums/{album_id}/tracks/add", post(admin_album_add_track))
.add("/admin/audio/albums/{album_id}/tracks/upload", get(admin_track_upload_form))
.add("/admin/audio/albums/{album_id}/tracks/upload-file", post(admin_track_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)))
.add("/admin/audio/tracks/{id}/publish", post(admin_track_publish))
.add("/admin/audio/tracks/{id}/unpublish", post(admin_track_unpublish))
.add("/admin/audio/tracks/{id}/remove-from-album", post(admin_track_remove_from_album))
.add(
"/admin/audio/tracks/upload-file",
post(admin_song_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)),
)
.add(
"/admin/audio/albums/{album_id}/tracks",
get(admin_album_tracks),
)
.add(
"/admin/audio/albums/{album_id}/tracks/add",
post(admin_album_add_track),
)
.add(
"/admin/audio/albums/{album_id}/tracks/upload",
get(admin_track_upload_form),
)
.add(
"/admin/audio/albums/{album_id}/tracks/upload-file",
post(admin_track_upload).layer(DefaultBodyLimit::max(AUDIO_MAX_BYTES + 1024 * 1024)),
)
.add(
"/admin/audio/tracks/{id}/publish",
post(admin_track_publish),
)
.add(
"/admin/audio/tracks/{id}/unpublish",
post(admin_track_unpublish),
)
.add(
"/admin/audio/tracks/{id}/remove-from-album",
post(admin_track_remove_from_album),
)
.add("/admin/audio/tracks/{id}/delete", post(admin_track_delete))
}

View File

@@ -1,7 +1,7 @@
pub mod admin;
pub mod auth;
pub mod blog;
pub mod i18n;
pub mod frontend;
pub mod i18n;
pub mod media;
pub mod pages;

View File

@@ -1,7 +1,4 @@
use crate::{
controllers::admin,
models::_entities::site_pages,
};
use crate::{controllers::admin, models::_entities::site_pages};
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};

View File

@@ -1,5 +1,5 @@
pub use super::_entities::audio_albums::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub use super::_entities::audio_albums::{ActiveModel, Model, Entity};
pub type AudioAlbums = Entity;
#[async_trait::async_trait]

View File

@@ -1,5 +1,5 @@
pub use super::_entities::audio_tags::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub use super::_entities::audio_tags::{ActiveModel, Model, Entity};
pub type AudioTags = Entity;
#[async_trait::async_trait]

View File

@@ -1,5 +1,5 @@
pub use super::_entities::audio_track_tags::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub use super::_entities::audio_track_tags::{ActiveModel, Model, Entity};
pub type AudioTrackTags = Entity;
#[async_trait::async_trait]

View File

@@ -1,5 +1,5 @@
pub use super::_entities::audio_tracks::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub use super::_entities::audio_tracks::{ActiveModel, Model, Entity};
pub type AudioTracks = Entity;
#[async_trait::async_trait]

View File

@@ -1,5 +1,5 @@
pub use super::_entities::audit_logs::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub use super::_entities::audit_logs::{ActiveModel, Model, Entity};
pub type AuditLogs = Entity;
#[async_trait::async_trait]

View File

@@ -1,5 +1,5 @@
pub use super::_entities::blog_articles::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub use super::_entities::blog_articles::{ActiveModel, Model, Entity};
pub type BlogArticles = Entity;
#[async_trait::async_trait]

View File

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

View File

@@ -1,5 +1,5 @@
use sea_orm::entity::prelude::*;
pub use super::_entities::site_pages::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub type SitePages = Entity;
#[async_trait::async_trait]

View File

@@ -3,7 +3,7 @@ use insta::assert_debug_snapshot;
use loco_rs::testing::prelude::*;
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
use serial_test::serial;
use universal_web::{
use gitara_web::{
app::App,
models::users::{self, Model, RegisterParams},
};

View File

@@ -2,7 +2,7 @@ use insta::{assert_debug_snapshot, with_settings};
use loco_rs::testing::prelude::*;
use rstest::rstest;
use serial_test::serial;
use universal_web::{app::App, models::users};
use gitara_web::{app::App, models::users};
use super::prepare_data;

View File

@@ -1,6 +1,6 @@
use axum::http::{HeaderName, HeaderValue};
use loco_rs::{app::AppContext, TestServer};
use universal_web::{models::users, views::auth::LoginResponse};
use gitara_web::{models::users, views::auth::LoginResponse};
const USER_EMAIL: &str = "test@loco.com";
const USER_PASSWORD: &str = "1234";