This commit is contained in:
eraden 2022-07-04 08:31:12 +02:00
commit 5469fda5de
18 changed files with 3416 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

2754
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "oswilno"
version = "0.1.0"
edition = "2021"
[dependencies]
actix = "*"
actix-web = "*"
actix-cors = "*"
actix-rt = "*"
actix-files = "*"
# actix-web-4-validator = { version = "3.2.0", default-features = false }
actix-4-jwt-auth = "0.4.2"
# actix-web-security = "*"
askama = { version = "*" }
validator = { version = "0.14", features = ["derive"] }
serde = { version = "*", features = ["derive"] }
serde_json = { version = "*" }
sqlx = { version = "*", features = ["runtime-actix-rustls", "postgres", "uuid", "chrono"] }
uuid = { version = "*", features = ["serde"] }
chrono = { version = "*", features = ["serde"] }
gumdrop = "*"
tracing = "*"
tracing-subscriber = "*"
tracing-actix-web = "*"

3
askama.toml Normal file
View File

@ -0,0 +1,3 @@
[general]
dirs = ["assets/templates"]
whitespace = "preserve"

64
assets/css/app.css Normal file
View File

@ -0,0 +1,64 @@
/* @media (min-width: 1200px) {
.bg {
display: block;
text-align: center;
width: 100%;
background:
radial-gradient(circle, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 45%, rgba(255,255,255,1) 100%),
no-repeat center image-set(url("/assets/images/background.webp"), url("/assets/images/background.jpeg"));
height: 200px;
}
.bg h1 {
text-align: center;
font-weight: bold;
font-size: 50px;
text-shadow: 2px 2px 2px #c5d1d8;
color: #FFF;
padding-top: 60px;
}
}*/
@import url('http://fonts.cdnfonts.com/css/noto-sans');
main {
font-family: 'Noto Sans', sans-serif;
}
* {
font-family: 'Noto Sans', sans-serif;
}
@media (min-width: 1200px) {
article {
width: 1280px;
margin: auto auto;
}
.bg {
height: 200px;
display: flex;
justify-content: space-between;
}
.bg::after {
display: block;
text-align: center;
width: 100%;
background:
linear-gradient(90deg, rgba(255,255,255,1) 0%, rgba(255,255,255,1) 1%, rgba(255,255,255,0) 100%),
no-repeat center image-set(url("/assets/images/background.webp"), url("/assets/images/background.jpeg"));
height: 200px;
width: calc(100% - 300px);
max-width: 1200px;
content: ' ';
}
.bg h1 {
font-weight: bold;
font-size: 50px;
text-shadow: 2px 2px 2px #c5d1d8;
text-align: center;
display: block;
width: 300px;
line-height: 4;
}
}

48
assets/css/reset.css Normal file
View File

@ -0,0 +1,48 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

207
assets/js/app.js Normal file
View File

@ -0,0 +1,207 @@
const S = Symbol();
customElements.define('local-services', class extends HTMLElement {
static get observedAttributes() {
return ['filter']
}
constructor() {
super();
const shadow = this[S] = this.attachShadow({ mode: 'closed' });
shadow.innerHTML = `
<style>
:host { display: block; }
* { font-family: 'Noto Sans', sans-serif; }
::slotted(local-service[local-services-visible="invisible"]) {
display: none;
}
</style>
<section>
<input type="text" id="filter" />
</section>
<section id="items">
<slot name="services"></slot>
</section>
`;
{
const filter = shadow.querySelector('#filter');
let t = null;
filter.addEventListener('change', ev => {
ev.stopPropagation();
this.filter = ev.target.value;
});
filter.addEventListener('keyup', ev => {
ev.stopPropagation();
if (t) clearTimeout(t);
t = setTimeout(() => {
this.filter = ev.target.value;
t = null;
}, 1000 / 3);
});
}
}
connectedCallback() {
this.filter = this.getAttribute('filter');
}
attributeChangedCallback(name, oldV, newV) {
if (oldV == newV) return;
switch (name) {
case 'filter': return this.filter = newV;
}
}
get filter() {
return this.getAttribute('filter');
}
set filter(value) {
this.setAttribute('filter', value);
for (const el of this.querySelectorAll('local-service')) {
if (!el.name) continue;
if (el.name.includes(value)) {
el.setAttribute('local-services-visible', 'visible');
} else {
el.setAttribute('local-services-visible', 'invisible');
}
}
}
});
customElements.define('local-service', class extends HTMLElement {
static get observedAttributes() {
return ['name', 'service-id', 'state']
}
constructor() {
super();
const shadow = this[S] = this.attachShadow({ mode: 'closed' });
shadow.innerHTML = `
<style>
:host { display: block; }
* { font-family: 'Noto Sans', sans-serif; }
</style>
<h2 id="name"></h2>
<slot name="description"></slot>
<section id="items">
<slot name="item"></slot>
</section>
`;
}
connectedCallback() {
this[S].querySelector('#name').textContent = this.getAttribute('name');
}
attributeChangedCallback(name, oldV, newV) {
switch (name) {
case 'name': return this[S].querySelector('#name').textContent = newV;
}
}
get name() {
return this.getAttribute('name') || ''
}
});
customElements.define('local-service-item', class extends HTMLElement {
static get observedAttributes() {
return ['name', 'price']
}
constructor() {
super();
const shadow = this[S] = this.attachShadow({ mode: 'closed' });
shadow.innerHTML = `
<style>
:host { display: block; }
* { font-family: 'Noto Sans', sans-serif; }
#item {
display: flex;
justify-content: space-between;
}
h3 {
font-weight: normal;
}
#price {
font-weight: bold;
}
</style>
<section id="item">
<h3 id="name"></h3>
<ow-price id="price" />
</section>
`;
}
connectedCallback() {
this[S].querySelector('#name').textContent = this.getAttribute('name');
this[S].querySelector('#price').value = this.price();
}
attributeChangedCallback(name, oldV, newV) {
switch (name) {
case 'name': return this[S].querySelector('#name').textContent = newV;
case 'price': return this[S].querySelector('#price').value = newV;
}
}
price(s) {
const n = parseInt(s || this.getAttribute('price'));
return isNaN(n) ? 0 : n;
}
});
customElements.define('ow-price', class extends HTMLElement {
static get observedAttributes() {
return ['value', 'currency']
}
constructor() {
super();
const shadow = this[S] = this.attachShadow({ mode: 'closed' });
shadow.innerHTML = `
<style>
:host { display: block; }
* { font-family: 'Noto Sans', sans-serif; }
#price {
font-weight: bold;
}
</style>
<span id="price"></span>
`;
}
connectedCallback() {
this[S].querySelector('#price').textContent = this.formatted;
}
attributeChangedCallback(name, oldV, newV) {
switch (name) {
case 'price': {
this.value = newV;
this[S].querySelector('#price').textContent = this.formatted;
break;
}
}
}
get formatted() {
let v = this.value;
let major = Math.ceil(v / 100);
let minor = v % 100;
let formatted = `${major},${ minor < 10 ? `0${minor}` : minor }`;
return `${formatted}${this.currency}`
}
get value() {
const n = parseInt(this.getAttribute('value'));
return isNaN(n) ? 0 : n;
}
set value(v) {
this.setAttribute('value', v)
}
get currency() {
return this.getAttribute('currency') || 'PLN'
}
});

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<title>OSWilno</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="/assets/css/reset.css" rel="stylesheet" />
<link href="/assets/css/app.css" rel="stylesheet" />
<script type="module" src=/assets/js/app.js></script>
</head>
<body>
<main>
<header>
<div class="bg">
<h1>OS Wilno</h1>
</div>
</header>
<article>
<h1>Lokalne usługi</h1>
<local-services>
{% for service in services %}
<local-service slot="services" service-id="{{service.id}}" name="{{service.name}}" state="{{service.state.to_str()}}">
{% for line in service.description.lines() %}
<p slot="description">{{line}}</p>
{% endfor %}
{% for item in service.items %}
<local-service-item slot="item" name="{{item.name}}" price="{{item.price}}">
</local-service-item>
{% endfor %}
</local-service>
{% endfor %}
</local-services>
</article>
</main>
</body>
</html>

View File

@ -0,0 +1,49 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE accounts (
id serial unique not null primary key,
login text not null,
pass text not null
);
CREATE TYPE "Role" AS ENUM (
'User',
'Admin'
);
CREATE TYPE "LocalServiceState" AS ENUM (
'Pending',
'Approved',
'Banned',
'Pinned',
'Internal'
);
CREATE TABLE tokens (
id serial unique not null primary key,
claims jsonb not null,
iss text not null default 'oswilno', /* issuer */
sub int references accounts (id), /* subject */
aud text not null default 'public', /* audience */
exp timestamp not null, /* expiration time */
nbt timestamp not null default now(), /* not before time */
iat timestamp not null default now(), /* issued at time */
jti uuid not null unique, /* JWT ID - unique */
role "Role" not null default 'User'
);
CREATE TABLE local_services (
id serial unique not null primary key,
owner_id int references accounts (id) not null,
name text not null,
description text not null,
state "LocalServiceState" not null default 'Pending'
);
CREATE TABLE local_service_items (
id serial unique not null primary key,
local_service_id int references local_services (id) not null,
name text not null,
price bigint not null,
item_order int not null
);

11
src/auth.rs Normal file
View File

@ -0,0 +1,11 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub struct FoundClaims {
pub iss: String,
pub sub: String,
pub aud: String,
pub name: String,
pub email: Option<String>,
pub email_verified: Option<bool>,
}

34
src/main.rs Normal file
View File

@ -0,0 +1,34 @@
#![feature(drain_filter)]
use actix_web::{App, HttpServer, web::Data};
mod auth;
mod model;
mod routes;
#[actix_web::main] // or #[tokio::main]
async fn main() -> std::io::Result<()> {
use actix_web::middleware::Logger;
use tracing::{Level};
use tracing_subscriber::FmtSubscriber;
let subscriber = FmtSubscriber::builder()
.with_max_level(Level::DEBUG)
.finish();
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(8)
.min_connections(8)
.connect(&std::env::var("DATABASE_URL").expect("No database connection url"))
.await
.unwrap();
HttpServer::new(move || {
App::new()
.wrap(Logger::default())
.app_data(Data::new(pool.clone()))
.configure(routes::configure)
})
.bind(("0.0.0.0", 8080))?
.run()
.await
}

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

@ -0,0 +1,71 @@
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Type};
use std::collections::HashMap;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Account {
pub id: i32,
pub login: String,
pub pass: String,
}
#[derive(Debug, Serialize, Deserialize, Type)]
pub enum Role {
User,
Admin,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize, Type)]
pub enum LocalServiceState {
Pending,
Approved,
Banned,
Pinned,
Internal,
}
impl LocalServiceState {
pub fn to_str(&self) -> &str {
match self {
Self::Pending => "Pending",
Self::Approved => "Approved",
Self::Banned => "Banned",
Self::Pinned => "Pinned",
Self::Internal => "Internal",
}
}
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Token {
pub id: i32,
pub claims: HashMap<String, String>,
pub iss: String, /* issuer */
pub sub: i32, /* subject */
pub aud: String, /* audience */
pub exp: NaiveDateTime, /* expiration time */
pub nbt: NaiveDateTime, /* not before time */
pub iat: NaiveDateTime, /* issued at time */
pub jti: Uuid, /* JWT ID - unique */
pub role: Role,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct LocalService {
pub id: i32,
pub owner_id: i32,
pub name: String,
pub description: String,
pub state: LocalServiceState,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct LocalServiceItem {
pub id: i32,
pub local_service_id: i32,
pub name: String,
pub price: i64,
pub item_order: i32,
}

2
src/model/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod db;
pub mod view;

27
src/model/view.rs Normal file
View File

@ -0,0 +1,27 @@
use crate::model::db;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct LocalService {
pub id: i32,
pub owner_id: i32,
pub name: String,
pub description: String,
pub state: db::LocalServiceState,
pub items: Vec<db::LocalServiceItem>,
}
impl<'v> From<(db::LocalService, &'v mut Vec<db::LocalServiceItem>)> for LocalService {
fn from((service, items): (db::LocalService, &'v mut Vec<db::LocalServiceItem>)) -> Self {
Self {
id: service.id,
owner_id: service.owner_id,
name: service.name,
description: service.description,
state: service.state,
items: items
.drain_filter(|i| i.local_service_id == service.id)
.collect(),
}
}
}

7
src/routes/mod.rs Normal file
View File

@ -0,0 +1,7 @@
use actix_web::web::ServiceConfig;
mod unrestricted;
pub fn configure(config: &mut ServiceConfig) {
unrestricted::configure(config);
}

View File

@ -0,0 +1,75 @@
use actix_files::Files;
use actix_web::web::{Data, ServiceConfig};
use actix_web::*;
use askama::Template;
#[derive(Template)]
#[template(path = "index.html")]
pub struct IndexTemplate {
pub services: Vec<crate::model::view::LocalService>,
}
#[get("/")]
pub async fn index(db: Data<sqlx::PgPool>) -> HttpResponse {
let pool = db.into_inner();
let (services, mut items) = {
use crate::model::db::{LocalService, LocalServiceItem};
let services: Vec<LocalService> = sqlx::query_as(
r#"
SELECT id, owner_id, name, description, state
FROM local_services
WHERE state != 'Banned'
GROUP BY id, state
ORDER BY id
"#,
)
.fetch_all(&*pool)
.await
.map_err(|e| {
eprintln!("{e}");
dbg!(&e);
e
})
.unwrap_or_default();
let items: Vec<LocalServiceItem> = sqlx::query_as(
r#"
SELECT
id,
local_service_id,
name,
price,
item_order
FROM local_service_items
"#,
)
.fetch_all(&*pool)
.await
.map_err(|e| {
eprintln!("{e}");
dbg!(&e);
e
})
.unwrap_or_default();
(services, items)
};
let services: Vec<_> = {
use crate::model::view::*;
services
.into_iter()
.map(|service| LocalService::from((service, &mut items)))
.collect()
};
let body = IndexTemplate { services }.render().unwrap();
HttpResponse::Ok().body(body)
}
pub fn configure(config: &mut ServiceConfig) {
config
.service(Files::new("/assets/images", "./assets/images"))
.service(Files::new("/assets/css", "./assets/css"))
.service(Files::new("/assets/js", "./assets/js").use_etag(true).prefer_utf8(true).show_files_listing())
.service(index);
}