Change state

This commit is contained in:
Adrian Woźniak 2022-07-14 19:36:33 +02:00
parent 16b79314fa
commit e1bd032e2a
No known key found for this signature in database
GPG Key ID: 0012845A89C7352B
34 changed files with 1276 additions and 574 deletions

View File

@ -1,24 +1,3 @@
/* @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('https://fonts.cdnfonts.com/css/noto-sans');
* {
@ -40,18 +19,18 @@ main {
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"));
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") 1x, url("/assets/images/background.jpeg") 1x);
height: 200px;
width: calc(100% - 300px);
max-width: 1200px;
@ -68,3 +47,60 @@ main {
line-height: 4;
}
}
* {
font-family: 'Noto Sans', sans-serif;
line-height: 1.6;
}
h1, h2, h3, h4, h5, h6 {
font-family: Georgia, "Times New Roman", Times, serif;
line-height: 1.2;
}
h1 {
font-size: 3.4em;
margin-bottom: .8em;
}
h2 {
font-size: 2.4em;
padding-bottom: .2em;
border-bottom: 1px solid #333;
margin-top: 1em;
margin-bottom: .7em;
}
h3 {
font-size: 1.8em;
margin-bottom: .7em;
}
p {
font-size: 1.3em;
line-height: 1.5;
margin-bottom: 1.3em;
}
ul, ol {
font-size: 1.3em;
margin: 0 0 1.3em 2em;
}
li {
margin-bottom: .4em;
}
blockquote {
padding-left: 1em;
border-left: 3px solid #999;
margin: 2.5em 2em;
}
blockquote p {
font: italic 1.2em/1.6 Georgia, "Times New Roman", Times, serif;
}
local-businesses local-business p {
font-family: 'Noto Sans', sans-serif;
}

View File

@ -16,33 +16,39 @@ 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;
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;
display: block;
}
body {
line-height: 1;
line-height: 1;
}
ol, ul {
list-style: none;
list-style: none;
}
blockquote, q {
quotes: none;
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
border-collapse: collapse;
border-spacing: 0;
}

View File

@ -0,0 +1,18 @@
{% extends "layout.html" %}
{% block content %}
<ow-admin>
<admin-businesses>
{% for business in businesses %}
<admin-edit-business business-id="{{ business.id }}" state="{{ business.state }}">
<admin-business
business-id="{{ business.id }}"
name="{{business.name}}"
state="{{ business.state }}"
>
{{business.description|safe}}
</admin-business>
</admin-edit-business>
{% endfor %}
</admin-businesses>
</ow-admin>
{% endblock %}

View File

@ -16,5 +16,6 @@
</edit-news-article>
{% endfor %}
</ow-articles>
<article-form></article-form>
</ow-admin>
{% endblock %}

View File

@ -34,15 +34,21 @@
{% endmatch %}
<article>
<ow-nav>
<ow-path path="/" selected="{{ page.select_index() }}">Lokalne Usługi</ow-path>
<ow-path path="/" selected="{{ page.select_marketplace() }}">Targ</ow-path>
<ow-path path="/news" selected="{{ page.select_news() }}">Aktualności</ow-path>
<ow-path path="/account" selected="{{ page.select_account() }}">Konto</ow-path>
{% match account.as_ref() %}
{% when Some with (a) %}
<ow-path path="/account/business-items" selected="{{ page.select_business_items() }}">Moje usługi</ow-path>
{% when None %}
{% endmatch %}
{% if page.is_public() %}
<ow-path path="/" selected="{{ page.select_index() }}">Lokalne Usługi</ow-path>
<ow-path path="/" selected="{{ page.select_marketplace() }}">Targ</ow-path>
<ow-path path="/news" selected="{{ page.select_news() }}">Aktualności</ow-path>
<ow-path path="/account" selected="{{ page.select_account() }}">Konto</ow-path>
{% match account.as_ref() %}
{% when Some with (a) %}
<ow-path path="/account/business-items" selected="{{ page.select_business_items() }}">Moje usługi</ow-path>
{% when None %}
{% endmatch %}
{% else if page.is_admin() %}
<ow-path path="/admin" selected="{{ page.select_admin_news() }}">Admin</ow-path>
<ow-path path="/admin/news" selected="{{ page.select_admin_news() }}">News</ow-path>
<ow-path path="/admin/businesses" selected="{{ page.select_admin_businesses() }}">Localne Usługi</ow-path>
{% endif %}
</ow-nav>
{% block content %}{% endblock %}
</article>

View File

@ -1,6 +1,10 @@
{% extends "base.html" %}
{% block content %}
<business-items>
<business-items
business-id="{{business.id}}"
name="{{business.name}}"
description="{{business.description}}"
>
{% for item in items %}
<business-item
item-id="{{item.id}}"

View File

@ -8,6 +8,7 @@
status="{{ article.status.as_str() }}"
published-at="{{ article.published_at|opt_time }}"
created-at="{{ article.created_at }}"
hide-status="true"
>
{{article.body|safe}}
</news-article>

View File

@ -2,3 +2,7 @@ import "./admin/ow-admin";
import "./admin/article-form";
import "./admin/edit-news-article";
import "./admin/admin-business";
import "./admin/admin-businesses";
import "./admin/admin-edit-business";

View File

@ -0,0 +1,40 @@
import { Component } from "../shared";
customElements.define('admin-business', class extends Component {
static get observedAttributes() {
return ['business-id', "name"];
}
constructor() {
super(`
<style>
:host {
display: block;
}
</style>
<article>
<h1 id="name"></h1>
</article>
<article>
<slot></slot>
</article>
`);
}
get business_id() {
return this.getAttribute('business-id');
}
set business_id(v) {
this.setAttribute('business-id', v);
}
get name() {
return this.getAttribute('name');
}
set name(v) {
this.setAttribute('name', v);
this.shadowRoot.querySelector('#name').textContent = v;
}
});

View File

@ -0,0 +1,12 @@
import { Component } from "../shared";
customElements.define('admin-businesses', class extends Component {
constructor() {
super(`
<style>
:host { display: block; }
</style>
<slot></slot>
`);
}
});

View File

@ -0,0 +1,66 @@
import { Component, BUTTON_STYLE, INPUT_STYLE } from "../shared";
customElements.define('admin-edit-business', class extends Component {
static get observedAttributes() {
return ['business-id', 'state'];
}
constructor() {
super(`
<style>
:host { display: block; }
section {
display: flex;
justify-content: space-between;
}
#state {
min-width: 200px;
}
#actions > input:not(:last-child) {
margin-right: .5rem;
}
${ BUTTON_STYLE }
${ INPUT_STYLE }
</style>
<section>
<slot></slot>
<div id="actions">
<input value="Usuń" type="button" />
<form id="change-state" action="/admin/businesses/set-state" method="post">
<input name="id" id="id" type="hidden" />
<select id="state" name="state">
<option value="Pending">Pending</option>
<option value="Approved">Approved</option>
<option value="Banned">Banned</option>
<option value="Pinned">Pinned</option>
<option value="Internal">Internal</option>
</select>
</form>
</div>
</section>
`);
const form = this.shadowRoot.querySelector('#change-state');
this.shadowRoot.querySelector('#state').addEventListener('change', ev => {
form.submit();
});
}
get business_id() {
return this.getAttribute('business-id');
}
set business_id(v) {
this.setAttribute('business-id', v);
this.shadowRoot.querySelector('#id').value = v;
}
get state() {
return this.getAttribute('state');
}
set state(v) {
this.setAttribute('state', v);
this.shadowRoot.querySelector('#state').value = v;
}
});

View File

@ -23,6 +23,7 @@ customElements.define('edit-news-article', class extends Component {
Edytuj
</ow-path>
<form method="post" action="/admin/news/delete">
<input name="id" id="id" type="hidden" />
<input class="link" type="submit" id="delete" value="Usuń" />
</form>
</article>
@ -32,10 +33,6 @@ customElements.define('edit-news-article', class extends Component {
ev.preventDefault();
ev.stopPropagation();
});
this.shadowRoot.querySelector('#delete').addEventListener('click', ev => {
ev.preventDefault();
ev.stopPropagation();
});
}
get article_id() {
@ -48,5 +45,6 @@ customElements.define('edit-news-article', class extends Component {
const id = this.article_id;
if (id === null) return;
this.shadowRoot.querySelector('ow-path').path = `/admin/news/${id}`;
this.shadowRoot.querySelector('#id').value = id;
}
});

View File

@ -7,7 +7,6 @@ customElements.define('ow-admin', class extends Component {
:host { display: block; }
</style>
<slot></slot>
<article-form></article-form>
`);
}
});

View File

@ -22,7 +22,7 @@ if (!document.querySelector('#facebook-jssdk')) {
xfbml: true,
version: 'v14.0'
});
FB.AppEvents.logPageView();
// FB.AppEvents.logPageView();
fireFbReady();
};

View File

@ -1,4 +1,4 @@
import { Component } from "./shared";
import { Component, FORM_STYLE } from "./shared";
import "./business-items/business-item";
import "./register-form/register-item-form-row";
@ -6,6 +6,10 @@ import "./register-form/register-item-form-row";
customElements.define('business-items', class extends Component {
#idx;
static get observedAttributes() {
return ['business-id', 'name', 'description']
}
constructor() {
super(`
<style>
@ -19,7 +23,27 @@ customElements.define('business-items', class extends Component {
#shadow {
width: 33px;
}
#description {
min-height: 200px;
}
${ FORM_STYLE }
</style>
<article id="business-details">
<form method="post" action="/business-item/atomic-update">
<input type="hidden" name="id" id="id">
<div>
<label>Nazwa</label>
<input name="name" id="name" />
</div>
<div>
<label>Opis</label>
<textarea name="description" id="description"></textarea>
</div>
<div>
<input type="submit" value="Zapisz" />
</div>
</form>
</article>
<article id="list">
<slot></slot>
</article>
@ -66,10 +90,10 @@ customElements.define('business-items', class extends Component {
ev.stopPropagation();
const item_id = ev.detail.id;
const current = this.querySelector(`business-item[item-id="${item_id}"]`);
if (!current) return console.warn(`business-item[item-id="${item_id}"] not found`);
const current = this.querySelector(`business-item[item-id="${ item_id }"]`);
if (!current) return console.warn(`business-item[item-id="${ item_id }"] not found`);
let prev = current.previousElementSibling;
if (!prev) return console.warn(`prev of business-item[item-id="${item_id}"] not found`);
if (!prev) return console.warn(`prev of business-item[item-id="${ item_id }"] not found`);
moveForm.querySelector('[name=id]').value = item_id;
moveForm.querySelector('[name=item_order]').value = prev.item_order;
moveForm.submit();
@ -79,7 +103,7 @@ customElements.define('business-items', class extends Component {
ev.stopPropagation();
const item_id = ev.detail.id;
const current = this.querySelector(`business-item[item-id="${item_id}"]`);
const current = this.querySelector(`business-item[item-id="${ item_id }"]`);
if (!current) return;
let next = current.nextElementSibling;
if (!next) return;
@ -109,4 +133,31 @@ customElements.define('business-items', class extends Component {
const el = this.shadowRoot.querySelector('register-item-form-row');
el.idx = this.#idx + 1;
}
get business_id() {
return this.getAttribute('business-id');
}
set business_id(v) {
this.setAttribute('business-id', v);
this.shadowRoot.querySelector('#id').value = v;
}
get name() {
return this.getAttribute('name');
}
set name(v) {
this.setAttribute('name', v);
this.shadowRoot.querySelector('#name').value = v;
}
get description() {
return this.getAttribute('description');
}
set description(v) {
this.setAttribute('description', v);
this.shadowRoot.querySelector('#description').textContent = (v || '').trim();
}
});

View File

@ -80,7 +80,6 @@ customElements.define('business-item', class extends Component {
ev.preventDefault();
ev.stopPropagation();
console.log(imageInput, ev);
this.picture_url = imageInput.url;
const updateForm = this.shadowRoot.querySelector('#updateForm');
@ -194,7 +193,6 @@ customElements.define('business-item', class extends Component {
}
set picture_url(v) {
console.log('picture_url', v);
if (!v.startsWith("/")) v = "";
this.setAttribute('picture-url', v);
this.shadowRoot.querySelector('image-input').url = v;

View File

@ -12,7 +12,7 @@ customElements.define('local-businesses', class extends Component {
<style>
:host { display: block; }
* { font-family: 'Noto Sans', sans-serif; }
::slotted(local-service[local-business-visible="invisible"]) {
::slotted(local-business[local-business-visible="invisible"]) {
display: none;
}
input {

View File

@ -13,6 +13,9 @@ customElements.define('local-business', class extends Component {
#items {
margin-top: 16px;
}
::slotted(local-business-item) {
margin-bottom: .5rem;
}
</style>
<h2 id="name"></h2>
<slot name="description"></slot>

View File

@ -38,8 +38,10 @@ customElements.define('ow-path', class extends Component {
}
set selected(value) {
if (value === 'selected') this.setAttribute('selected', 'selected');
else this.removeAttribute('selected');
if (value === true || value === 'selected')
this.setAttribute('selected', 'selected');
else
this.removeAttribute('selected');
}
get path() {

View File

@ -1,9 +1,9 @@
import { Component } from "../shared";
import { BLOCK_QUOTE_STYLE, Component } from "../shared";
import "../shared/date-time";
customElements.define('news-article', class extends Component {
static get observedAttributes() {
return ["article-id", "article-title", "status", "body", "created-at", "published-at"]
return ["article-id", "article-title", "status", "body", "created-at", "published-at", "hide-status"]
}
constructor() {
@ -34,6 +34,14 @@ customElements.define('news-article', class extends Component {
margin-bottom: 16px;
margin-top: 16px;
}
.time, date-time {
font-size: 12px;
font-style: italic;
}
:host([hide-status="true"]) #status {
display: none;
}
${ BLOCK_QUOTE_STYLE }
</style>
<article>
<h1>
@ -42,12 +50,12 @@ customElements.define('news-article', class extends Component {
</h1>
<section id="time">
<div class="time">
<span>Created at:</span>
<span>Napisano:</span>
<date-time id="created_at" hide-date="false" hide-time="false">
</date-time>
</div>
<div class="time">
<span>Published at:</span>
<span>Opublikowano:</span>
<date-time id="published_at" hide-date="false" hide-time="false">
</date-time>
</div>
@ -101,4 +109,12 @@ customElements.define('news-article', class extends Component {
set published_at(v) {
this.shadowRoot.querySelector('#published_at').datetime = v;
}
get hide_status() {
return this.getAttribute('hide-status') === 'true';
}
set hide_status(v) {
this.setAttribute('hide-status', v);
}
});

View File

@ -68,14 +68,6 @@ customElements.define('ow-account', class extends Component {
ev.stopPropagation();
ev.preventDefault();
this.mode = 'facebook';
const { id, name, email } = ev.details;
console.info({ id, name, email })
// form.querySelector('#email').value = email;
// form.querySelector('#login').value = name;
// form.querySelector('#password').value = crypto.randomUUID();
// form.querySelector('#facebook_id').value = id;
// form.submit();
});
}

View File

@ -33,7 +33,6 @@ customElements.define('register-basic-form', class extends PseudoForm {
this.shadowRoot.querySelector('form-navigation').next();
});
this.shadowRoot.querySelector('form-navigation').addEventListener('form:next', ev => {
console.log(ev);
});
}
});

View File

@ -107,7 +107,6 @@ customElements.define('register-item-form-row', class extends PseudoForm {
#updateNames() {
const idx = this.idx;
for (const el of this.#inputs) {
console.log(el);
el.setAttribute('name', `items[${ idx }][${ el.id }]`);
}
}

View File

@ -78,6 +78,17 @@ textarea {
}
`;
export const BLOCK_QUOTE_STYLE = `
blockquote {
font: 14px/22px normal helvetica, sans-serif;
margin-top: 10px;
margin-bottom: 10px;
margin-left: 50px;
padding-left: 15px;
border-left: 3px solid var(--border-slim-color);
}
`;
export const FORM_STYLE = `
form {
display: block;

View File

@ -41,11 +41,10 @@ customElements.define('facebook-button', class extends Component {
ev.stopPropagation();
ev.preventDefault();
if (!this.#fb_sdk)
return console.info("Facebook SDK is not available");
return console.warn("Facebook SDK is not available");
FB.login((res) => {
if (res.status === 'connected') {
FB.api("/me?fields=id,name,email", ({ id, name, email }) => {
console.info({ id, name, email });
this.dispatchEvent(new CustomEvent('facebook:account', {
bubbles: true, composed: true, detail: {
id, name, email

View File

@ -1,4 +1,4 @@
import { Component } from "../shared";
import { Component, BLOCK_QUOTE_STYLE } from "../shared";
customElements.define('rich-text-editor', class extends Component {
#selection;
@ -60,6 +60,7 @@ customElements.define('rich-text-editor', class extends Component {
#text-color input, #background-color input {
display: none;
}
${ BLOCK_QUOTE_STYLE }
</style>
<article>
<section id="tools">
@ -119,6 +120,12 @@ customElements.define('rich-text-editor', class extends Component {
<path d="M487.2 69.7c0 12.9-10.5 23.4-23.4 23.4h-322c-12.9 0-23.4-10.5-23.4-23.4s10.5-23.4 23.4-23.4h322.1c12.9.1 23.3 10.5 23.3 23.4zm-23.3 92.6H141.8c-12.9 0-23.4 10.5-23.4 23.4s10.5 23.4 23.4 23.4h322.1c12.9 0 23.4-10.5 23.4-23.4-.1-12.9-10.5-23.4-23.4-23.4zm0 116H141.8c-12.9 0-23.4 10.5-23.4 23.4s10.5 23.4 23.4 23.4h322.1c12.9 0 23.4-10.5 23.4-23.4-.1-12.9-10.5-23.4-23.4-23.4zm0 116H141.8c-12.9 0-23.4 10.5-23.4 23.4s10.5 23.4 23.4 23.4h322.1c12.9 0 23.4-10.5 23.4-23.4-.1-12.9-10.5-23.4-23.4-23.4zM38.9 30.8C17.4 30.8 0 48.2 0 69.7s17.4 39 38.9 39 38.9-17.5 38.9-39-17.4-38.9-38.9-38.9zm0 116C17.4 146.8 0 164.2 0 185.7s17.4 38.9 38.9 38.9 38.9-17.4 38.9-38.9-17.4-38.9-38.9-38.9zm0 116C17.4 262.8 0 280.2 0 301.7s17.4 38.9 38.9 38.9 38.9-17.4 38.9-38.9-17.4-38.9-38.9-38.9zm0 115.9C17.4 378.7 0 396.1 0 417.6s17.4 38.9 38.9 38.9 38.9-17.4 38.9-38.9c0-21.4-17.4-38.9-38.9-38.9z"/>
</svg>
</button>
<button id="short-quote" title="Cytat">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M11.192 15.757c0-.88-.23-1.618-.69-2.217-.326-.412-.768-.683-1.327-.812-.55-.128-1.07-.137-1.54-.028-.16-.95.1-1.956.76-3.022.66-1.065 1.515-1.867 2.558-2.403L9.373 5c-.8.396-1.56.898-2.26 1.505-.71.607-1.34 1.305-1.9 2.094s-.98 1.68-1.25 2.69-.346 2.04-.217 3.1c.168 1.4.62 2.52 1.356 3.35.735.84 1.652 1.26 2.748 1.26.965 0 1.766-.29 2.4-.878.628-.576.94-1.365.94-2.368l.002.003zm9.124 0c0-.88-.23-1.618-.69-2.217-.326-.42-.77-.692-1.327-.817-.56-.124-1.074-.13-1.54-.022-.16-.94.09-1.95.75-3.02.66-1.06 1.514-1.86 2.557-2.4L18.49 5c-.8.396-1.555.898-2.26 1.505a11.29 11.29 0 0 0-1.894 2.094c-.556.79-.97 1.68-1.24 2.69a8.04 8.04 0 0 0-.217 3.1c.165 1.4.615 2.52 1.35 3.35.732.833 1.646 1.25 2.742 1.25.967 0 1.768-.29 2.402-.876.627-.576.942-1.365.942-2.368v.01z"/>
</svg>
</button>
</div>
<div id="insert">
<button id="image" title="Wstaw obrazek">
@ -181,16 +188,23 @@ customElements.define('rich-text-editor', class extends Component {
return this.#setWrap(['H5'], { repeat: 'ignore' });
}
});
this.shadowRoot.querySelector('#ordered-list').addEventListener('click', ev => {
ev.stopPropagation();
this.#saveSelection();
this.#setWrap(['ol', 'li'], { repeat: 'perform' });
});
this.shadowRoot.querySelector('#unordered-list').addEventListener('click', ev => {
ev.stopPropagation();
this.#saveSelection();
this.#setWrap(['ul', 'li'], { repeat: 'perform' });
});
{
this.shadowRoot.querySelector('#ordered-list').addEventListener('click', ev => {
ev.stopPropagation();
this.#saveSelection();
this.#setWrap(['ol', 'li'], { repeat: 'perform' });
});
this.shadowRoot.querySelector('#unordered-list').addEventListener('click', ev => {
ev.stopPropagation();
this.#saveSelection();
this.#setWrap(['ul', 'li'], { repeat: 'perform' });
});
this.shadowRoot.querySelector('#short-quote').addEventListener('click', ev => {
ev.stopPropagation();
this.#saveSelection();
this.#setWrap(['blockquote'], { repeat: 'drop' });
});
}
{
let timeout = null;
this.shadowRoot.querySelector('#edit').addEventListener('keyup', (ev) => {

View File

@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::fmt::{Display, Formatter};
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
@ -37,6 +38,12 @@ pub enum LocalBusinessState {
Internal,
}
impl Display for LocalBusinessState {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl LocalBusinessState {
pub fn as_str(&self) -> &str {
match self {
@ -126,6 +133,13 @@ pub struct UpdateNewsArticleInput {
pub published_at: Option<NaiveDateTime>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct UpdateLocalBusinessInput {
pub id: i32,
pub name: String,
pub description: String,
}
#[derive(Debug)]
pub struct CreateLocalBusinessItemInput {
pub local_business_id: i32,
@ -144,3 +158,8 @@ pub struct UpdateLocalBusinessItemInput {
pub item_order: i32,
pub picture_url: String,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct DeleteNewsArticleInput {
pub id: i32,
}

View File

@ -7,15 +7,36 @@ pub enum Page {
LocalBusinesses,
News,
Account,
Admin,
AdminCreateNews,
Register,
Login,
BusinessItems,
Marketplace,
AdminNews,
AdminCreateNews,
AdminBusinesses,
}
impl Page {
pub fn is_public(&self) -> bool {
matches!(
self,
Page::LocalBusinesses
| Page::News
| Page::Account
| Page::Register
| Page::Login
| Page::BusinessItems
| Page::Marketplace
)
}
pub fn is_admin(&self) -> bool {
matches!(
self,
Page::AdminNews | Page::AdminCreateNews | Page::AdminBusinesses
)
}
pub fn select_index(&self) -> &str {
if matches!(self, Page::LocalBusinesses) {
"selected"
@ -55,6 +76,22 @@ impl Page {
""
}
}
pub fn select_admin_news(&self) -> &str {
if matches!(self, Page::AdminNews) {
"selected"
} else {
""
}
}
pub fn select_admin_businesses(&self) -> &str {
if matches!(self, Page::AdminBusinesses) {
"selected"
} else {
""
}
}
}
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -64,6 +101,12 @@ pub struct BusinessItemInput {
pub picture_url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SetStateBusinessInput {
pub id: i32,
pub state: db::LocalBusinessState,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LocalBusiness {
pub id: i32,
@ -97,6 +140,13 @@ pub struct CreateBusinessItemInput {
pub item_order: i32,
}
#[derive(Debug, Deserialize)]
pub struct AtomicUpdateBusinessItemInput {
pub id: i32,
pub name: String,
pub description: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateBusinessItemInput {
pub id: i32,
@ -131,3 +181,8 @@ pub struct CreateNewsArticleInput {
pub body: String,
pub status: db::NewsStatus,
}
#[derive(Debug, Deserialize)]
pub struct DeleteNewsArticleInput {
pub id: i32,
}

View File

@ -7,10 +7,15 @@ use crate::model::db::NewsArticle;
#[derive(Debug)]
pub enum Error {
VisibleBusinesses,
SetOrder {
item_id: i32,
idx: i32,
},
UpdateBusiness {
input: db::UpdateLocalBusinessInput,
},
VisibleBusinessItems,
DeleteItem {
item_id: i32,
},
@ -45,6 +50,9 @@ pub enum Error {
UpdateNewsArticle {
input: db::UpdateNewsArticleInput,
},
DeleteNewsArticle {
id: i32,
},
}
pub type Result<T> = std::result::Result<T, Error>;
@ -168,7 +176,10 @@ WHERE id = $1 :: INT
}
#[tracing::instrument]
pub async fn account_business_by_id(t: &mut T<'_>, account_id: i32) -> Result<db::LocalBusiness> {
pub async fn account_business_by_owner_id(
t: &mut T<'_>,
account_id: i32,
) -> Result<db::LocalBusiness> {
sqlx::query_as(
r#"
SELECT id, owner_id, name, description, state
@ -188,6 +199,35 @@ ORDER BY id DESC
})
}
#[tracing::instrument]
pub async fn update_business(
t: &mut T<'_>,
input: db::UpdateLocalBusinessInput,
) -> Result<db::LocalBusiness> {
sqlx::query_as(
r#"
UPDATE local_businesses
SET
name = $2,
description = $3
WHERE
id = $1
RETURNING
id, owner_id, name, description, state
"#,
)
.bind(input.id)
.bind(&input.name)
.bind(&input.description)
.fetch_one(t)
.await
.map_err(|e| {
error!("{e}");
dbg!(e);
Error::UpdateBusiness { input }
})
}
#[tracing::instrument]
pub async fn account_items(t: &mut T<'_>, account_id: i32) -> Vec<db::LocalBusinessItem> {
sqlx::query_as(
@ -570,3 +610,145 @@ RETURNING
Error::UpdateNewsArticle { input }
})
}
#[tracing::instrument]
pub async fn delete_news_article(t: &mut T<'_>, id: i32) -> Result<Option<NewsArticle>> {
sqlx::query_as(
r#"
DELETE FROM news
WHERE id = $1
RETURNING
id,
title,
body,
status,
published_at,
created_at
"#,
)
.bind(id)
.fetch_optional(t)
.await
.map_err(|e| {
error!("{e}");
dbg!(e);
Error::DeleteNewsArticle { id }
})
}
#[tracing::instrument]
pub async fn visible_businesses(t: &mut T<'_>) -> Result<Vec<db::LocalBusiness>> {
sqlx::query_as(
r#"
SELECT id, owner_id, name, description, state
FROM local_businesses
WHERE state != 'Banned'
GROUP BY id, state
ORDER BY id DESC
"#,
)
.fetch_all(t)
.await
.map_err(|e| {
error!("{e}");
dbg!(&e);
Error::VisibleBusinesses
})
}
#[tracing::instrument]
pub async fn visible_business_items(t: &mut T<'_>) -> Result<Vec<db::LocalBusinessItem>> {
sqlx::query_as(
r#"
SELECT
id,
local_business_id,
name,
price,
item_order,
picture_url
FROM local_business_items
ORDER BY item_order ASC
"#,
)
.fetch_all(t)
.await
.map_err(|e| {
error!("{e}");
dbg!(&e);
Error::VisibleBusinessItems
})
}
#[tracing::instrument]
pub async fn all_businesses(t: &mut T<'_>) -> Result<Vec<db::LocalBusiness>> {
sqlx::query_as(
r#"
SELECT id, owner_id, name, description, state
FROM local_businesses
GROUP BY id, state
ORDER BY id DESC
"#,
)
.fetch_all(t)
.await
.map_err(|e| {
error!("{e}");
dbg!(&e);
Error::VisibleBusinesses
})
}
#[tracing::instrument]
pub async fn all_business_items(t: &mut T<'_>) -> Result<Vec<db::LocalBusinessItem>> {
sqlx::query_as(
r#"
SELECT
id,
local_business_id,
name,
price,
item_order,
picture_url
FROM local_business_items
ORDER BY item_order ASC
"#,
)
.fetch_all(t)
.await
.map_err(|e| {
error!("{e}");
dbg!(&e);
Error::VisibleBusinessItems
})
}
#[tracing::instrument]
pub async fn set_business_state(
t: &mut T<'_>,
id: i32,
state: db::LocalBusinessState,
) -> Result<db::LocalBusiness> {
sqlx::query_as(
r#"
UPDATE local_businesses
SET state = $2
WHERE id = $1
RETURNING
id,
owner_id,
name,
description,
state
"#,
)
.bind(id)
.bind(state)
.fetch_one(t)
.await
.map_err(|e| {
error!("{e}");
dbg!(&e);
Error::VisibleBusinessItems
})
}

View File

@ -19,7 +19,7 @@ macro_rules! ok_or_internal {
match $try {
Ok(res) => res,
Err(e) => {
tracing::error!("{e}");
tracing::error!("{e:?}");
dbg!(e);
return Err($crate::routes::Error::DatabaseQuery);
}
@ -29,7 +29,7 @@ macro_rules! ok_or_internal {
match $try {
Ok(res) => res,
Err(e) => {
tracing::error!("{e}");
tracing::error!("{e:?}");
dbg!(e);
return Err($crate::routes::Error::DatabaseQuery.to_json());
}
@ -37,6 +37,17 @@ macro_rules! ok_or_internal {
};
}
#[macro_export]
macro_rules! not_xss {
($s: expr, $t: expr) => {
if let Err(e) = crate::routes::reject_xss($s) {
dbg!(&e);
$t.rollback().await.unwrap();
return Err(e);
}
};
}
pub use unrestricted::render_index;
pub fn configure(config: &mut ServiceConfig) {
@ -87,6 +98,15 @@ pub enum Error {
OwnedBusinessNotFound { account_id: i32 },
OwnedBusinessItemNotFound { account_id: i32, business_id: i32 },
DatabaseQuery,
XSS,
}
pub fn reject_xss(s: &str) -> Result<()> {
if s.contains("<script ") || s.contains("<script>") {
Ok(())
} else {
Err(Error::XSS)
}
}
impl Error {
@ -121,6 +141,7 @@ impl Display for Error {
f.write_str("Problem z zapisaniem zmian. Proszę spróbować później")
}
Error::Forbidden => f.write_str("Tylko admin może wejść na tę stronę"),
Error::XSS => f.write_str("Wykryto próbę XSS"),
}
}
}
@ -134,6 +155,7 @@ impl ResponseError for Error {
Error::UploadFailed => StatusCode::BAD_REQUEST,
Error::DatabaseQuery => StatusCode::INTERNAL_SERVER_ERROR,
Error::Forbidden => StatusCode::FORBIDDEN,
Error::XSS => StatusCode::IM_A_TEAPOT,
}
}
}
@ -185,6 +207,7 @@ impl ResponseError for JsonError {
Error::UploadFailed => StatusCode::BAD_REQUEST,
Error::DatabaseQuery => StatusCode::INTERNAL_SERVER_ERROR,
Error::Forbidden => StatusCode::FORBIDDEN,
Error::XSS => StatusCode::IM_A_TEAPOT,
}
}
}

View File

@ -6,6 +6,8 @@ use sqlx::PgPool;
use crate::model::{db, view};
use crate::queries;
use crate::routes::{Identity, Result};
pub mod admin;
mod business_item;
#[derive(Debug, Template)]
#[template(path = "business-items.html")]
@ -14,8 +16,10 @@ struct BusinessItemsTemplate {
error: Option<String>,
account: Option<db::Account>,
items: Vec<db::LocalBusinessItem>,
business: db::LocalBusiness,
}
#[macro_export]
macro_rules! authorize {
($t: expr, $id: expr) => {{
let account = match $id.identity() {
@ -62,434 +66,21 @@ async fn handle_business_items_page(
) -> Result<HttpResponse> {
let account = authorize!(t, id);
let business =
crate::ok_or_internal!(queries::account_business_by_owner_id(t, account.id).await);
let items: Vec<db::LocalBusinessItem> = queries::account_items(t, account.id).await;
let page = BusinessItemsTemplate {
page: view::Page::BusinessItems,
error: None,
account: Some(account),
items,
business,
};
Ok(HttpResponse::Ok()
.content_type("text/html")
.body(page.render().unwrap()))
}
mod business_item {
use actix_web::web::{Data, Form, ServiceConfig};
use actix_web::{post, HttpResponse};
use sqlx::PgPool;
use tracing::{error, info};
use crate::model::{db, view};
use crate::routes::{Error, Identity, Result};
use crate::{queries, routes};
#[post("/update")]
#[tracing::instrument]
async fn update_business_item(
form: Form<view::UpdateBusinessItemInput>,
db: Data<PgPool>,
id: Identity,
) -> Result<HttpResponse> {
let form = form.into_inner();
dbg!(&form);
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let account = authorize!(&mut t, id);
{
let business: db::LocalBusiness =
match queries::account_business_by_id(&mut t, account.id).await {
Ok(business) => business,
Err(e) => {
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Err(routes::Error::OwnedBusinessNotFound {
account_id: account.id,
});
}
};
let item: db::LocalBusinessItem = match queries::update_item(
&mut t,
db::UpdateLocalBusinessItemInput {
id: form.id,
local_business_id: business.id,
name: form.name,
price: form.price,
item_order: form.item_order,
picture_url: form.picture_url,
},
)
.await
{
Ok(item) => item,
Err(e) => {
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Err(Error::OwnedBusinessItemNotFound {
account_id: account.id,
business_id: business.id,
});
}
};
info!("{:?}", item);
}
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/account/business-items"))
.finish())
}
#[post("/new")]
#[tracing::instrument]
async fn new_business_item(
form: Form<view::CreateBusinessItemInput>,
db: Data<PgPool>,
id: Identity,
) -> Result<HttpResponse> {
let form = form.into_inner();
dbg!(&form);
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let account = authorize!(&mut t, id);
let business: db::LocalBusiness =
match queries::account_business_by_id(&mut t, account.id).await {
Ok(business) => business,
Err(e) => {
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Err(routes::Error::OwnedBusinessNotFound {
account_id: account.id,
});
}
};
if let Err(e) = queries::create_item(
&mut t,
db::CreateLocalBusinessItemInput {
local_business_id: business.id,
name: form.name,
price: form.price,
item_order: form.item_order,
picture_url: form.picture_url,
},
)
.await
{
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Err(Error::OwnedBusinessItemNotFound {
account_id: account.id,
business_id: business.id,
});
};
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/account/business-items"))
.finish())
}
#[post("/delete")]
#[tracing::instrument]
async fn delete_business_item(
form: Form<view::ModifyBusinessItemInput>,
db: Data<PgPool>,
id: Identity,
) -> Result<HttpResponse> {
let form = form.into_inner();
dbg!(&form);
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let account = authorize!(&mut t, id);
if let Err(e) = queries::delete_item(&mut t, form.id).await {
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Ok(HttpResponse::BadRequest().finish());
}
let items = queries::account_items(&mut t, account.id).await;
for (idx, item) in items.into_iter().enumerate() {
if let Err(e) = queries::set_item_order(&mut t, item.id, idx as i32 + 1).await {
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Ok(HttpResponse::BadRequest().finish());
}
}
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/account/business-items"))
.finish())
}
#[post("/move")]
#[tracing::instrument]
async fn move_item(
form: Form<view::MoveBusinessItemInput>,
db: Data<PgPool>,
id: Identity,
) -> Result<HttpResponse> {
let form = form.into_inner();
dbg!(&form);
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let account = authorize!(&mut t, id);
match queries::move_item(&mut t, account.id, form.id, form.item_order).await {
Ok(item) => item,
Err(e) => {
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Ok(HttpResponse::BadRequest().finish());
}
};
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/account/business-items"))
.finish())
}
pub fn configure(config: &mut ServiceConfig) {
config.service(
actix_web::web::scope("/business-item")
.service(new_business_item)
.service(update_business_item)
.service(delete_business_item)
.service(move_item),
);
}
}
mod admin {
use actix_web::web::{Data, Form, Path, ServiceConfig};
use actix_web::{get, post, web, HttpResponse};
use askama::*;
use sqlx::PgPool;
use crate::model::view::Page;
use crate::model::{db, view};
use crate::queries;
use crate::routes::{Identity, JsonResult, Result};
use crate::view::filters;
macro_rules! require_admin {
($t: expr, $id: expr) => {{
let account = authorize!(&mut $t, $id);
if account.account_type == crate::model::db::AccountType::Admin {
return Err(crate::routes::Error::Forbidden);
}
account
}};
(json; $t: expr, $id: expr) => {{
let account = authorize!(json; &mut $t, $id);
if account.account_type == crate::model::db::AccountType::Admin {
return Err(crate::routes::Error::Forbidden.to_json());
}
account
}};
}
#[derive(Debug, Template)]
#[template(path = "admin/index.html")]
struct AdminTemplate {
page: view::Page,
error: Option<String>,
account: Option<db::Account>,
news: Vec<db::NewsArticle>,
}
#[get("")]
async fn admin(db: Data<PgPool>, id: Identity) -> Result<HttpResponse> {
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let _account = require_admin!(&mut t, id);
let news = queries::all_news(&mut t).await.unwrap_or_default();
t.commit().await.ok();
Ok(HttpResponse::Ok().content_type("text/html").body(
AdminTemplate {
page: Page::Admin,
error: None,
account: None,
news,
}
.render()
.unwrap(),
))
}
#[derive(Debug, Template)]
#[template(path = "admin/edit.html")]
struct EditTemplate {
page: view::Page,
error: Option<String>,
account: Option<db::Account>,
article: db::NewsArticle,
}
#[get("/{id}")]
async fn edit_news_article(
path: Path<(i32,)>,
db: Data<PgPool>,
id: Identity,
) -> Result<HttpResponse> {
let article_id = path.into_inner().0;
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let _account = require_admin!(&mut t, id);
let article = match queries::news_article_by_id(&mut t, article_id).await {
Ok(article) => article,
Err(e) => {
dbg!(e);
return Ok(HttpResponse::BadRequest().finish());
}
};
t.commit().await.ok();
Ok(HttpResponse::Ok().content_type("text/html").body(
EditTemplate {
page: Page::Admin,
error: None,
account: None,
article,
}
.render()
.unwrap(),
))
}
#[post("/create")]
async fn create_news_article(
db: Data<PgPool>,
id: Identity,
form: Form<view::CreateNewsArticleInput>,
) -> Result<HttpResponse> {
let form = form.into_inner();
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let _account = require_admin!(&mut t, id);
if let Err(e) = queries::create_news_article(
&mut t,
db::CreateNewsArticleInput {
title: form.title,
body: form.body,
status: form.status,
},
)
.await
{
dbg!(e);
t.rollback().await.ok();
return Ok(HttpResponse::BadRequest().content_type("text/html").body(
AdminTemplate {
page: Page::AdminCreateNews,
error: Some("Failed".into()),
account: None,
news: vec![],
}
.render()
.unwrap(),
));
}
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/admin"))
.finish())
}
#[post("/update")]
async fn update_news_article(
db: Data<PgPool>,
id: Identity,
form: Form<view::UpdateNewsArticleInput>,
) -> Result<HttpResponse> {
let form = form.into_inner();
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let _account = require_admin!(&mut t, id);
match queries::update_news_article(
&mut t,
db::UpdateNewsArticleInput {
id: form.id,
title: form.title,
body: form.body,
status: form.status,
published_at: if matches!(form.status, db::NewsStatus::Published) {
Some(chrono::Utc::now().naive_utc())
} else {
None
},
},
)
.await
{
Err(e) => {
dbg!(e);
t.rollback().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/admin"))
.finish())
}
Ok(..) => {
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/admin"))
.finish())
}
}
}
#[post("/upload")]
async fn news_article_upload(
payload: actix_multipart::Multipart,
db: Data<PgPool>,
id: Identity,
) -> JsonResult<HttpResponse> {
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(json pool.begin().await);
let account = require_admin!(json; t, id);
t.commit().await.ok();
crate::routes::uploads::hande_upload(payload, Some(account.id), "news").await
}
pub fn configure(config: &mut ServiceConfig) {
config.service(
web::scope("/admin")
.service(
web::scope("/news")
.service(create_news_article)
.service(news_article_upload)
.service(edit_news_article)
.service(update_news_article),
)
.service(admin),
);
}
}
pub fn configure(config: &mut ServiceConfig) {
config.service(business_items_page);
business_item::configure(config);

View File

@ -0,0 +1,334 @@
use actix_web::web::{Data, Form, Path, ServiceConfig};
use actix_web::{get, post, web, HttpResponse};
use askama::*;
use sqlx::PgPool;
use crate::model::view::{Page, SetStateBusinessInput};
use crate::model::{db, view};
use crate::routes::{Identity, JsonResult, Result};
use crate::view::filters;
use crate::{authorize, queries};
#[macro_export]
macro_rules! require_admin {
($t: expr, $id: expr) => {{
let account = authorize!(&mut $t, $id);
if account.account_type == crate::model::db::AccountType::Admin {
return Err(crate::routes::Error::Forbidden);
}
account
}};
(json; $t: expr, $id: expr) => {{
let account = authorize!(json; &mut $t, $id);
if account.account_type == crate::model::db::AccountType::Admin {
return Err(crate::routes::Error::Forbidden.to_json());
}
account
}};
}
#[derive(Debug, Template)]
#[template(path = "admin/news.html")]
struct AdminNewsTemplate {
page: view::Page,
error: Option<String>,
account: Option<db::Account>,
news: Vec<db::NewsArticle>,
}
#[get("")]
async fn admin(db: Data<PgPool>, id: Identity) -> Result<HttpResponse> {
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let _account = require_admin!(&mut t, id);
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/admin/news"))
.finish())
}
#[get("")]
async fn admin_news(db: Data<PgPool>, id: Identity) -> Result<HttpResponse> {
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let _account = require_admin!(&mut t, id);
let news = queries::all_news(&mut t).await.unwrap_or_default();
t.commit().await.ok();
Ok(HttpResponse::Ok().content_type("text/html").body(
AdminNewsTemplate {
page: Page::AdminNews,
error: None,
account: None,
news,
}
.render()
.unwrap(),
))
}
#[derive(Debug, Template)]
#[template(path = "admin/edit.html")]
struct EditTemplate {
page: view::Page,
error: Option<String>,
account: Option<db::Account>,
article: db::NewsArticle,
}
#[get("/{id}")]
async fn edit_news_article(
path: Path<(i32,)>,
db: Data<PgPool>,
id: Identity,
) -> Result<HttpResponse> {
let article_id = path.into_inner().0;
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let _account = require_admin!(&mut t, id);
let article = match queries::news_article_by_id(&mut t, article_id).await {
Ok(article) => article,
Err(e) => {
dbg!(e);
return Ok(HttpResponse::BadRequest().finish());
}
};
t.commit().await.ok();
Ok(HttpResponse::Ok().content_type("text/html").body(
EditTemplate {
page: Page::AdminNews,
error: None,
account: None,
article,
}
.render()
.unwrap(),
))
}
#[post("/create")]
async fn create_news_article(
db: Data<PgPool>,
id: Identity,
form: Form<view::CreateNewsArticleInput>,
) -> Result<HttpResponse> {
let form = form.into_inner();
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let _account = require_admin!(&mut t, id);
if let Err(e) = queries::create_news_article(
&mut t,
db::CreateNewsArticleInput {
title: form.title,
body: form.body,
status: form.status,
},
)
.await
{
dbg!(e);
t.rollback().await.ok();
return Ok(HttpResponse::BadRequest().content_type("text/html").body(
AdminNewsTemplate {
page: Page::AdminCreateNews,
error: Some("Failed".into()),
account: None,
news: vec![],
}
.render()
.unwrap(),
));
}
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/admin/news"))
.finish())
}
#[post("/update")]
async fn update_news_article(
db: Data<PgPool>,
id: Identity,
form: Form<view::UpdateNewsArticleInput>,
) -> Result<HttpResponse> {
let form = form.into_inner();
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let _account = require_admin!(&mut t, id);
match queries::update_news_article(
&mut t,
db::UpdateNewsArticleInput {
id: form.id,
title: form.title,
body: form.body,
status: form.status,
published_at: if matches!(form.status, db::NewsStatus::Published) {
Some(chrono::Utc::now().naive_utc())
} else {
None
},
},
)
.await
{
Err(e) => {
dbg!(e);
t.rollback().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/admin/news"))
.finish())
}
Ok(..) => {
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/admin/news"))
.finish())
}
}
}
#[post("/delete")]
async fn delete_news_article(
db: Data<PgPool>,
id: Identity,
form: Form<view::DeleteNewsArticleInput>,
) -> Result<HttpResponse> {
let form = form.into_inner();
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let _account = require_admin!(&mut t, id);
match queries::delete_news_article(&mut t, form.id).await {
Err(e) => {
dbg!(e);
t.rollback().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/admin"))
.finish())
}
Ok(..) => {
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/admin"))
.finish())
}
}
}
#[post("/upload")]
async fn news_article_upload(
payload: actix_multipart::Multipart,
db: Data<PgPool>,
id: Identity,
) -> JsonResult<HttpResponse> {
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(json pool.begin().await);
let account = require_admin!(json; t, id);
t.commit().await.ok();
crate::routes::uploads::hande_upload(payload, Some(account.id), "news").await
}
#[derive(Debug, Template)]
#[template(path = "admin/businesses.html")]
struct AdminBusinessesTemplate {
page: view::Page,
error: Option<String>,
account: Option<db::Account>,
businesses: Vec<view::LocalBusiness>,
}
#[get("")]
async fn admin_businesses(db: Data<PgPool>, id: Identity) -> Result<HttpResponse> {
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let _account = require_admin!(&mut t, id);
let (services, mut items) = {
use crate::model::db::{LocalBusiness, LocalBusinessItem};
let services: Vec<LocalBusiness> =
queries::all_businesses(&mut t).await.unwrap_or_default();
let items: Vec<LocalBusinessItem> = queries::all_business_items(&mut t)
.await
.unwrap_or_default();
(services, items)
};
let businesses: Vec<_> = services
.into_iter()
.map(|service| view::LocalBusiness::from((service, &mut items)))
.collect();
t.commit().await.ok();
Ok(HttpResponse::Ok().content_type("text/html").body(
AdminBusinessesTemplate {
page: Page::AdminBusinesses,
error: None,
account: None,
businesses,
}
.render()
.unwrap(),
))
}
#[post("/set-state")]
async fn admin_business_set_state(
db: Data<PgPool>,
id: Identity,
form: Form<SetStateBusinessInput>,
) -> Result<HttpResponse> {
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let _account = require_admin!(&mut t, id);
let form = form.into_inner();
dbg!(&form);
if let Err(e) = queries::set_business_state(&mut t, form.id, form.state).await {
dbg!(e);
}
if let Err(e) = t.commit().await {
dbg!(e);
}
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/admin/businesses"))
.finish())
}
pub fn configure(config: &mut ServiceConfig) {
config.service(
web::scope("/admin")
.service(
web::scope("/news")
.service(admin_news)
.service(create_news_article)
.service(news_article_upload)
.service(edit_news_article)
.service(update_news_article)
.service(delete_news_article),
)
.service(
web::scope("/businesses")
.service(admin_businesses)
.service(admin_business_set_state),
)
.service(admin),
);
}

View File

@ -0,0 +1,245 @@
use actix_web::web::{Data, Form, ServiceConfig};
use actix_web::{post, HttpResponse};
use sqlx::PgPool;
use tracing::{error, info};
use crate::model::{db, view};
use crate::routes::{Error, Identity, Result};
use crate::{authorize, not_xss, queries, routes};
#[post("/update")]
#[tracing::instrument]
async fn update_business_item(
form: Form<view::UpdateBusinessItemInput>,
db: Data<PgPool>,
id: Identity,
) -> Result<HttpResponse> {
let form = form.into_inner();
dbg!(&form);
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let account = authorize!(&mut t, id);
not_xss!(&form.name, t);
not_xss!(&form.picture_url, t);
{
let business: db::LocalBusiness =
match queries::account_business_by_owner_id(&mut t, account.id).await {
Ok(business) => business,
Err(e) => {
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Err(routes::Error::OwnedBusinessNotFound {
account_id: account.id,
});
}
};
let item: db::LocalBusinessItem = match queries::update_item(
&mut t,
db::UpdateLocalBusinessItemInput {
id: form.id,
local_business_id: business.id,
name: form.name,
price: form.price,
item_order: form.item_order,
picture_url: form.picture_url,
},
)
.await
{
Ok(item) => item,
Err(e) => {
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Err(Error::OwnedBusinessItemNotFound {
account_id: account.id,
business_id: business.id,
});
}
};
info!("{:?}", item);
}
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/account/business-items"))
.finish())
}
#[post("/atomic-update")]
#[tracing::instrument]
async fn atomic_update_business_item(
form: Form<view::AtomicUpdateBusinessItemInput>,
db: Data<PgPool>,
id: Identity,
) -> Result<HttpResponse> {
let form = form.into_inner();
dbg!(&form);
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let account = authorize!(&mut t, id);
not_xss!(&form.name, t);
not_xss!(&form.description, t);
{
let business: db::LocalBusiness = match queries::update_business(
&mut t,
db::UpdateLocalBusinessInput {
id: form.id,
name: form.name,
description: form.description,
},
)
.await
{
Ok(item) => item,
Err(e) => {
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Err(Error::OwnedBusinessItemNotFound {
account_id: account.id,
business_id: form.id,
});
}
};
info!("{:?}", business);
}
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/account/business-items"))
.finish())
}
#[post("/new")]
#[tracing::instrument]
async fn new_business_item(
form: Form<view::CreateBusinessItemInput>,
db: Data<PgPool>,
id: Identity,
) -> Result<HttpResponse> {
let form = form.into_inner();
dbg!(&form);
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let account = authorize!(&mut t, id);
not_xss!(&form.name, t);
not_xss!(&form.picture_url, t);
let business: db::LocalBusiness =
match queries::account_business_by_owner_id(&mut t, account.id).await {
Ok(business) => business,
Err(e) => {
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Err(routes::Error::OwnedBusinessNotFound {
account_id: account.id,
});
}
};
if let Err(e) = queries::create_item(
&mut t,
db::CreateLocalBusinessItemInput {
local_business_id: business.id,
name: form.name,
price: form.price,
item_order: form.item_order,
picture_url: form.picture_url,
},
)
.await
{
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Err(Error::OwnedBusinessItemNotFound {
account_id: account.id,
business_id: business.id,
});
};
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/account/business-items"))
.finish())
}
#[post("/delete")]
#[tracing::instrument]
async fn delete_business_item(
form: Form<view::ModifyBusinessItemInput>,
db: Data<PgPool>,
id: Identity,
) -> Result<HttpResponse> {
let form = form.into_inner();
dbg!(&form);
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let account = authorize!(&mut t, id);
if let Err(e) = queries::delete_item(&mut t, form.id).await {
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Ok(HttpResponse::BadRequest().finish());
}
let items = queries::account_items(&mut t, account.id).await;
for (idx, item) in items.into_iter().enumerate() {
if let Err(e) = queries::set_item_order(&mut t, item.id, idx as i32 + 1).await {
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Ok(HttpResponse::BadRequest().finish());
}
}
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/account/business-items"))
.finish())
}
#[post("/move")]
#[tracing::instrument]
async fn move_item(
form: Form<view::MoveBusinessItemInput>,
db: Data<PgPool>,
id: Identity,
) -> Result<HttpResponse> {
let form = form.into_inner();
dbg!(&form);
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let account = authorize!(&mut t, id);
match queries::move_item(&mut t, account.id, form.id, form.item_order).await {
Ok(item) => item,
Err(e) => {
error!("{e:?}");
dbg!(e);
t.rollback().await.ok();
return Ok(HttpResponse::BadRequest().finish());
}
};
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/account/business-items"))
.finish())
}
pub fn configure(config: &mut ServiceConfig) {
config.service(
actix_web::web::scope("/business-item")
.service(new_business_item)
.service(update_business_item)
.service(delete_business_item)
.service(move_item)
.service(atomic_update_business_item),
);
}

View File

@ -12,7 +12,7 @@ use crate::model::db;
use crate::model::view::{self, Page};
use crate::routes::{Identity, JsonResult, Result};
use crate::view::filters;
use crate::{queries, utils};
use crate::{not_xss, queries, routes, utils};
#[derive(Template)]
#[template(path = "index.html")]
@ -39,7 +39,7 @@ pub async fn render_index() -> HttpResponse {
#[get("/")]
#[tracing::instrument]
pub async fn index(db: Data<sqlx::PgPool>, id: Identity) -> Result<HttpResponse> {
pub async fn index(db: Data<PgPool>, id: Identity) -> Result<HttpResponse> {
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let record = match id.identity() {
@ -48,45 +48,13 @@ pub async fn index(db: Data<sqlx::PgPool>, id: Identity) -> Result<HttpResponse>
};
let (services, mut items) = {
use crate::model::db::{LocalBusiness, LocalBusinessItem};
let services: Vec<LocalBusiness> = sqlx::query_as(
r#"
SELECT id, owner_id, name, description, state
FROM local_businesses
WHERE state != 'Banned'
GROUP BY id, state
ORDER BY id DESC
"#,
)
.fetch_all(&*pool)
.await
.map_err(|e| {
error!("{e}");
dbg!(&e);
e
})
.unwrap_or_default();
let services: Vec<LocalBusiness> = queries::visible_businesses(&mut t)
.await
.unwrap_or_default();
let items: Vec<LocalBusinessItem> = sqlx::query_as(
r#"
SELECT
id,
local_business_id,
name,
price,
item_order,
picture_url
FROM local_business_items
ORDER BY item_order ASC
"#,
)
.fetch_all(&*pool)
.await
.map_err(|e| {
error!("{e}");
dbg!(&e);
e
})
.unwrap_or_default();
let items: Vec<LocalBusinessItem> = queries::visible_business_items(&mut t)
.await
.unwrap_or_default();
(services, items)
};
@ -122,7 +90,7 @@ struct AccountTemplate {
#[get("/account")]
#[tracing::instrument]
async fn account_page(id: Identity, db: Data<sqlx::PgPool>) -> Result<HttpResponse> {
async fn account_page(id: Identity, db: Data<PgPool>) -> Result<HttpResponse> {
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let account = match id.identity() {
@ -207,14 +175,18 @@ fn process_items(items: &mut Vec<view::BusinessItemInput>, names: HashMap<String
#[post("/register")]
#[tracing::instrument]
async fn register(form: web::Form<RegisterForm>, db: Data<PgPool>, id: Identity) -> HttpResponse {
async fn register(
form: web::Form<RegisterForm>,
db: Data<PgPool>,
id: Identity,
) -> Result<HttpResponse> {
let mut form = form.into_inner();
dbg!(&form);
process_items(form.items.get_or_insert_default(), form.names);
let pool = db.into_inner();
if form.account_type == db::AccountType::Admin {
return HttpResponse::BadRequest().body("Security breach attempt detected!");
return Ok(HttpResponse::BadRequest().body("Security breach attempt detected!"));
}
let mut t = pool.begin().await.unwrap();
@ -225,7 +197,7 @@ async fn register(form: web::Form<RegisterForm>, db: Data<PgPool>, id: Identity)
tracing::error!("{:?}", e);
dbg!(e);
t.rollback().await.unwrap();
return HttpResponse::BadRequest().body(
return Ok(HttpResponse::BadRequest().body(
AccountTemplate {
account: None,
error: Some("Zapisanie hasła nie powiodło się".into()),
@ -233,7 +205,7 @@ async fn register(form: web::Form<RegisterForm>, db: Data<PgPool>, id: Identity)
}
.render()
.unwrap(),
);
));
}
};
@ -260,7 +232,7 @@ RETURNING id, login, email, pass, facebook_id, account_type
tracing::error!("{e}");
dbg!(e);
t.rollback().await.unwrap();
return HttpResponse::BadRequest().body(
return Ok(HttpResponse::BadRequest().body(
AccountTemplate {
account: None,
error: Some("Problem z utworzeniem konta".into()),
@ -268,7 +240,7 @@ RETURNING id, login, email, pass, facebook_id, account_type
}
.render()
.unwrap(),
);
));
}
};
@ -277,6 +249,9 @@ RETURNING id, login, email, pass, facebook_id, account_type
let owner_id = account.id;
let description = form.description.as_deref().unwrap_or_default();
not_xss!(name, t);
not_xss!(description, t);
let res: sqlx::Result<db::LocalBusiness> = sqlx::query_as(
r#"
INSERT INTO local_businesses (name, owner_id, description)
@ -295,7 +270,7 @@ RETURNING id, owner_id, name, description, state
tracing::error!("{e}");
dbg!(e);
t.rollback().await.unwrap();
return HttpResponse::BadRequest().body(
return Ok(HttpResponse::BadRequest().body(
AccountTemplate {
account: None,
error: Some("Problem z utworzeniem konta".into()),
@ -303,11 +278,14 @@ RETURNING id, owner_id, name, description, state
}
.render()
.unwrap(),
);
));
}
};
for (idx, item) in form.items.unwrap_or_default().into_iter().enumerate() {
not_xss!(&item.name, t);
not_xss!(&item.picture_url, t);
let res: sqlx::Result<db::LocalBusinessItem> = sqlx::query_as(
r#"
INSERT INTO local_business_items (local_business_id, name, price, item_order, picture_url)
@ -328,7 +306,7 @@ RETURNING id, local_business_id, name, price, item_order, picture_url
error!("{e} {:?}", dir);
dbg!(e);
t.rollback().await.unwrap();
return HttpResponse::BadRequest().content_type("text/html").body(
return Ok(HttpResponse::BadRequest().content_type("text/html").body(
AccountTemplate {
account: None,
error: Some(
@ -338,14 +316,14 @@ RETURNING id, local_business_id, name, price, item_order, picture_url
}
.render()
.unwrap(),
);
));
}
let path = dir.join(name);
if let Err(e) = std::fs::rename(format!(".{}", item.picture_url), &path) {
error!("{e} {:?}", item.picture_url);
dbg!(e);
t.rollback().await.unwrap();
return HttpResponse::BadRequest().content_type("text/html").body(
return Ok(HttpResponse::BadRequest().content_type("text/html").body(
AccountTemplate {
account: None,
error: Some(
@ -355,7 +333,7 @@ RETURNING id, local_business_id, name, price, item_order, picture_url
}
.render()
.unwrap(),
);
));
}
let path = path.to_str().map(String::from).unwrap_or_default();
path.strip_prefix('.')
@ -370,7 +348,7 @@ RETURNING id, local_business_id, name, price, item_order, picture_url
tracing::error!("{e}");
dbg!(e);
t.rollback().await.unwrap();
return HttpResponse::BadRequest().content_type("text/html").body(
return Ok(HttpResponse::BadRequest().content_type("text/html").body(
AccountTemplate {
account: None,
error: Some("Problem z utworzeniem konta".into()),
@ -378,7 +356,7 @@ RETURNING id, local_business_id, name, price, item_order, picture_url
}
.render()
.unwrap(),
);
));
}
}
}
@ -386,7 +364,7 @@ RETURNING id, local_business_id, name, price, item_order, picture_url
t.commit().await.unwrap();
HttpResponse::SeeOther()
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/"))
.body(
AccountTemplate {
@ -396,7 +374,7 @@ RETURNING id, local_business_id, name, price, item_order, picture_url
}
.render()
.unwrap(),
)
))
}
#[post("/logout")]
@ -487,7 +465,7 @@ async fn upload(
_ => None,
};
t.commit().await.ok();
crate::routes::uploads::hande_upload(payload, id, "accounts").await
routes::uploads::hande_upload(payload, id, "accounts").await
}
#[derive(Template)]