Groups
This commit is contained in:
parent
c782124bcd
commit
296ec44fe4
File diff suppressed because it is too large
Load Diff
27
Cargo.toml
27
Cargo.toml
|
@ -2,19 +2,20 @@
|
|||
name = "gmarche"
|
||||
version = "0.1.0"
|
||||
authors = ["Pascal Engélibert <tuxmain@zettascript.org>"]
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
async-std = { version = "1.8.0", features = ["attributes"] }
|
||||
bincode = "1.3.1"
|
||||
argon2 = "0.3.4"
|
||||
async-std = { version = "1.10.0", features = ["attributes"] }
|
||||
bincode = "1.3.3"
|
||||
bs58 = "0.4.0"
|
||||
dirs = "3.0.1"
|
||||
handlebars = "3.5.2"
|
||||
hex = "0.4.2"
|
||||
rand = "0.8.1"
|
||||
serde = { version = "1.0.118", features = ["derive"] }
|
||||
serde_json = "1.0.61"
|
||||
sha2 = "0.9.2"
|
||||
sled = "0.34.6"
|
||||
structopt = "0.3.21"
|
||||
tide = { version = "0.15.0", default-features = false, features = ["h1-server", "cookies", "logger"] }
|
||||
clap = { version = "3.1.1", default-features = false, features = ["derive", "std"] }
|
||||
directories = "4.0.1"
|
||||
handlebars = "4.2.1"
|
||||
hex = "0.4.3"
|
||||
rand = "0.8.5"
|
||||
serde = { version = "1.0.136", features = ["derive"] }
|
||||
serde_json = "1.0.79"
|
||||
sha2 = "0.10.2"
|
||||
sled = "0.34.7"
|
||||
tide = { version = "0.16.0", default-features = false, features = ["h1-server", "cookies", "logger"] }
|
||||
|
|
19
src/cli.rs
19
src/cli.rs
|
@ -1,8 +1,8 @@
|
|||
use clap::Parser;
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use structopt::StructOpt;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConfigPath(pub PathBuf);
|
||||
|
@ -10,9 +10,10 @@ pub struct ConfigPath(pub PathBuf);
|
|||
impl Default for ConfigPath {
|
||||
fn default() -> ConfigPath {
|
||||
ConfigPath(
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| Path::new(".config").to_path_buf())
|
||||
.join("gmarche"),
|
||||
directories::ProjectDirs::from("tk", "txmn", "gmarche").map_or_else(
|
||||
|| Path::new(".config").to_path_buf(),
|
||||
|o| o.config_dir().into(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -34,19 +35,19 @@ impl ToString for ConfigPath {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, StructOpt)]
|
||||
#[structopt(name = "gmarche")]
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
#[clap(name = "gmarche")]
|
||||
pub struct MainOpt {
|
||||
/// Directory
|
||||
#[structopt(short, long, parse(from_os_str), default_value)]
|
||||
#[clap(short, long, parse(from_os_str), default_value_t=ConfigPath::default())]
|
||||
pub dir: ConfigPath,
|
||||
|
||||
/// Subcommand
|
||||
#[structopt(subcommand)]
|
||||
#[clap(subcommand)]
|
||||
pub cmd: MainSubcommand,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, StructOpt)]
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
pub enum MainSubcommand {
|
||||
/// Initialize config & db
|
||||
Init,
|
||||
|
|
18
src/db.rs
18
src/db.rs
|
@ -3,17 +3,29 @@ use std::path::Path;
|
|||
|
||||
const DB_DIR: &str = "db";
|
||||
|
||||
const DB_NAME_ADS: &str = "ads";
|
||||
const DB_NAME_AD: &str = "ad";
|
||||
const DB_NAME_AD_BY_GROUP: &str = "ad_by_group";
|
||||
const DB_NAME_GROUP: &str = "group";
|
||||
const DB_NAME_GROUP_BY_GROUP: &str = "group_by_group";
|
||||
const DB_NAME_GROUP_BY_NAME: &str = "group_by_name";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Dbs {
|
||||
pub ads: Tree,
|
||||
pub ad: Tree,
|
||||
pub ad_by_group: Tree,
|
||||
pub group: Tree,
|
||||
pub group_by_group: Tree,
|
||||
pub group_by_name: Tree,
|
||||
}
|
||||
|
||||
pub fn load_dbs(path: &Path) -> Dbs {
|
||||
let db = sled::open(path.join(DB_DIR)).expect("Cannot open db");
|
||||
|
||||
Dbs {
|
||||
ads: db.open_tree(DB_NAME_ADS).unwrap(),
|
||||
ad: db.open_tree(DB_NAME_AD).unwrap(),
|
||||
ad_by_group: db.open_tree(DB_NAME_AD_BY_GROUP).unwrap(),
|
||||
group: db.open_tree(DB_NAME_GROUP).unwrap(),
|
||||
group_by_group: db.open_tree(DB_NAME_GROUP_BY_GROUP).unwrap(),
|
||||
group_by_name: db.open_tree(DB_NAME_GROUP_BY_NAME).unwrap(),
|
||||
}
|
||||
}
|
||||
|
|
24
src/main.rs
24
src/main.rs
|
@ -9,11 +9,13 @@ mod static_files;
|
|||
mod templates;
|
||||
mod utils;
|
||||
|
||||
use structopt::StructOpt;
|
||||
use utils::*;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[async_std::main]
|
||||
async fn main() {
|
||||
let opt = cli::MainOpt::from_args();
|
||||
let opt = cli::MainOpt::parse();
|
||||
|
||||
match opt.cmd {
|
||||
cli::MainSubcommand::Init => {
|
||||
|
@ -29,9 +31,25 @@ async fn main() {
|
|||
fn init_all<'reg>(opt: cli::MainOpt) -> (config::Config, db::Dbs, templates::Templates<'reg>) {
|
||||
std::fs::create_dir_all(&opt.dir.0).expect("Cannot create dir");
|
||||
static_files::init_static_files(&opt.dir.0);
|
||||
let dbs = db::load_dbs(&opt.dir.0);
|
||||
|
||||
if !dbs.group.contains_key(&ROOT_GROUP_ID).unwrap() {
|
||||
dbs.group
|
||||
.insert(
|
||||
ROOT_GROUP_ID,
|
||||
bincode::serialize(&Group {
|
||||
name: String::new(),
|
||||
parent: ROOT_GROUP_ID,
|
||||
title: String::from("ĞMarché"),
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
(
|
||||
config::read_config(&opt.dir.0),
|
||||
db::load_dbs(&opt.dir.0),
|
||||
dbs,
|
||||
templates::load_templates(&opt.dir.0),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,15 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminLoginQuery {
|
||||
pub psw: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminRmAdQuery {
|
||||
pub ad: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct NewAdQuery {
|
||||
pub author: String,
|
||||
|
@ -10,6 +20,13 @@ pub struct NewAdQuery {
|
|||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct AdminNewGroupQuery {
|
||||
pub parent: String,
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct RmAdQuery {
|
||||
pub ad: String,
|
||||
|
@ -17,31 +34,23 @@ pub struct RmAdQuery {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminRmAdQuery {
|
||||
pub ad: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminLoginQuery {
|
||||
pub psw: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(tag = "a")]
|
||||
pub enum Query {
|
||||
pub enum IndexQuery {
|
||||
#[serde(rename = "new_ad")]
|
||||
NewAdQuery(NewAdQuery),
|
||||
NewAd(NewAdQuery),
|
||||
#[serde(rename = "rm_ad")]
|
||||
RmAdQuery(RmAdQuery),
|
||||
RmAd(RmAdQuery),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(tag = "a")]
|
||||
pub enum AdminQuery {
|
||||
#[serde(rename = "login")]
|
||||
LoginQuery(AdminLoginQuery),
|
||||
Login(AdminLoginQuery),
|
||||
#[serde(rename = "new_group")]
|
||||
NewGroup(AdminNewGroupQuery),
|
||||
#[serde(rename = "rm_ad")]
|
||||
RmAdQuery(AdminRmAdQuery),
|
||||
RmAd(AdminRmAdQuery),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
372
src/server.rs
372
src/server.rs
|
@ -1,11 +1,11 @@
|
|||
use super::{cli, config::*, db::*, queries::*, static_files, templates::*, utils::*};
|
||||
|
||||
use handlebars::to_json;
|
||||
use sha2::{Digest, Sha512Trunc256};
|
||||
use std::{
|
||||
convert::{TryFrom, TryInto},
|
||||
sync::Arc,
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use handlebars::to_json;
|
||||
use std::{convert::TryFrom, sync::Arc};
|
||||
|
||||
pub async fn start_server(
|
||||
config: Config,
|
||||
|
@ -45,6 +45,44 @@ pub async fn start_server(
|
|||
handle_post_index(req, config.clone(), templates.clone(), dbs.clone())
|
||||
}
|
||||
});
|
||||
app.at(&format!("{}g/:group", config.root_url)).get({
|
||||
let config = config.clone();
|
||||
let templates = templates.clone();
|
||||
let dbs = dbs.clone();
|
||||
move |req: tide::Request<()>| {
|
||||
serve_group(
|
||||
req,
|
||||
config.clone(),
|
||||
templates.clone(),
|
||||
dbs.clone(),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
}
|
||||
});
|
||||
app.at(&format!("{}g/:group/:ad", config.root_url)).get({
|
||||
let config = config.clone();
|
||||
let templates = templates.clone();
|
||||
let dbs = dbs.clone();
|
||||
move |req: tide::Request<()>| {
|
||||
serve_group(
|
||||
req,
|
||||
config.clone(),
|
||||
templates.clone(),
|
||||
dbs.clone(),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
}
|
||||
});
|
||||
app.at(&format!("{}admin/g/:group", config.root_url)).get({
|
||||
let config = config.clone();
|
||||
let templates = templates.clone();
|
||||
let dbs = dbs.clone();
|
||||
move |req: tide::Request<()>| {
|
||||
handle_admin_group(req, config.clone(), templates.clone(), dbs.clone())
|
||||
}
|
||||
});
|
||||
app.at(&format!("{}ad/:ad", config.root_url)).post({
|
||||
let config = config.clone();
|
||||
let templates = templates.clone();
|
||||
|
@ -129,11 +167,12 @@ async fn serve_index<'a>(
|
|||
errors,
|
||||
},
|
||||
ads: &to_json(
|
||||
dbs.ads
|
||||
.iter()
|
||||
dbs.ad_by_group
|
||||
.scan_prefix(ROOT_GROUP_ID)
|
||||
.filter_map(|x| {
|
||||
let (ad_id, ad) = x.ok()?;
|
||||
let ad_id = hex::encode(ad_id.as_ref());
|
||||
let (k, ad) = x.ok()?;
|
||||
let ad_id =
|
||||
hex::encode(AdId::try_from(&k.as_ref()[16..32]).ok()?);
|
||||
Some(AdWithId {
|
||||
ad: bincode::deserialize::<Ad>(&ad).ok()?,
|
||||
selected: selected_ad.map_or(false, |i| i == ad_id),
|
||||
|
@ -142,6 +181,16 @@ async fn serve_index<'a>(
|
|||
})
|
||||
.collect::<Vec<AdWithId>>(),
|
||||
),
|
||||
groups: &to_json(
|
||||
dbs.group_by_group
|
||||
.scan_prefix(ROOT_GROUP_ID)
|
||||
.keys()
|
||||
.filter_map(|k| GroupId::try_from(&k.ok()?.as_ref()[16..32]).ok())
|
||||
.filter_map(|subgroup_id| {
|
||||
bincode::deserialize(&dbs.group.get(subgroup_id).ok()??).ok()
|
||||
})
|
||||
.collect::<Vec<Group>>(),
|
||||
),
|
||||
new_ad_form_refill,
|
||||
},
|
||||
)
|
||||
|
@ -150,6 +199,171 @@ async fn serve_index<'a>(
|
|||
.build())
|
||||
}
|
||||
|
||||
async fn serve_group<'a>(
|
||||
req: tide::Request<()>,
|
||||
config: Arc<Config>,
|
||||
templates: Arc<Templates<'static>>,
|
||||
dbs: Dbs,
|
||||
errors: &[ErrorTemplate<'a>],
|
||||
new_ad_form_refill: Option<NewAdQuery>,
|
||||
) -> tide::Result<tide::Response> {
|
||||
if let Ok(group_name) = req.param("group") {
|
||||
if let Some(group_id) = dbs.group_by_name.get(group_name).unwrap() {
|
||||
let selected_ad = req.param("ad").ok();
|
||||
|
||||
Ok(tide::Response::builder(200)
|
||||
.content_type(tide::http::mime::HTML)
|
||||
.body(
|
||||
templates
|
||||
.hb
|
||||
.render(
|
||||
"group.html",
|
||||
&GroupTemplate {
|
||||
common: CommonTemplate {
|
||||
lang: "fr",
|
||||
root_url: &config.root_url,
|
||||
title: &config.title,
|
||||
errors,
|
||||
},
|
||||
ads: &to_json(
|
||||
dbs.ad_by_group
|
||||
.scan_prefix(group_id.clone())
|
||||
.filter_map(|x| {
|
||||
let (k, ad) = x.ok()?;
|
||||
let ad_id = hex::encode(
|
||||
AdId::try_from(&k.as_ref()[16..32]).ok()?,
|
||||
);
|
||||
Some(AdWithId {
|
||||
ad: bincode::deserialize::<Ad>(&ad).ok()?,
|
||||
selected: selected_ad.map_or(false, |i| i == ad_id),
|
||||
id: ad_id,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<AdWithId>>(),
|
||||
),
|
||||
groups: &to_json(
|
||||
dbs.group_by_group
|
||||
.scan_prefix(group_id)
|
||||
.keys()
|
||||
.filter_map(|k| {
|
||||
GroupId::try_from(&k.ok()?.as_ref()[16..32]).ok()
|
||||
})
|
||||
.filter_map(|subgroup_id| {
|
||||
bincode::deserialize(&dbs.group.get(subgroup_id).ok()??)
|
||||
.ok()
|
||||
})
|
||||
.collect::<Vec<Group>>(),
|
||||
),
|
||||
new_ad_form_refill,
|
||||
},
|
||||
)
|
||||
.unwrap_or_else(|e| e.to_string()),
|
||||
)
|
||||
.build())
|
||||
} else {
|
||||
serve_index(
|
||||
req,
|
||||
config,
|
||||
templates,
|
||||
dbs,
|
||||
&[ErrorTemplate {
|
||||
text: "Le groupe demandé n'existe pas.",
|
||||
}],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
serve_index(req, config, templates, dbs, &[], None).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_admin_group<'a>(
|
||||
req: tide::Request<()>,
|
||||
config: Arc<Config>,
|
||||
templates: Arc<Templates<'static>>,
|
||||
dbs: Dbs,
|
||||
errors: &[ErrorTemplate<'a>],
|
||||
new_group_form_refill: Option<AdminNewGroupQuery>,
|
||||
) -> tide::Result<tide::Response> {
|
||||
if let Ok(group_name) = req.param("group") {
|
||||
if let Some(group_id) = dbs.group_by_name.get(group_name).unwrap() {
|
||||
if let Some(group) = dbs.group.get(&group_id).unwrap() {
|
||||
if let Ok(group) = bincode::deserialize::<Group>(&group) {
|
||||
let selected_ad = req.param("ad").ok();
|
||||
|
||||
return Ok(tide::Response::builder(200)
|
||||
.content_type(tide::http::mime::HTML)
|
||||
.body(
|
||||
templates
|
||||
.hb
|
||||
.render(
|
||||
"admin_group.html",
|
||||
&AdminGroupTemplate {
|
||||
common: CommonTemplate {
|
||||
lang: "fr",
|
||||
root_url: &config.root_url,
|
||||
title: &config.title,
|
||||
errors,
|
||||
},
|
||||
ads: &to_json(
|
||||
dbs.ad_by_group
|
||||
.scan_prefix(group_id.clone())
|
||||
.filter_map(|x| {
|
||||
let (k, ad) = x.ok()?;
|
||||
let ad_id = hex::encode(
|
||||
AdId::try_from(&k.as_ref()[16..32]).ok()?,
|
||||
);
|
||||
Some(AdWithId {
|
||||
ad: bincode::deserialize::<Ad>(&ad).ok()?,
|
||||
selected: selected_ad
|
||||
.map_or(false, |i| i == ad_id),
|
||||
id: ad_id,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<AdWithId>>(),
|
||||
),
|
||||
group: &to_json(group),
|
||||
groups: &to_json(
|
||||
dbs.group_by_group
|
||||
.scan_prefix(group_id)
|
||||
.keys()
|
||||
.filter_map(|k| {
|
||||
GroupId::try_from(&k.ok()?.as_ref()[16..32])
|
||||
.ok()
|
||||
})
|
||||
.filter_map(|subgroup_id| {
|
||||
bincode::deserialize(
|
||||
&dbs.group.get(subgroup_id).ok()??,
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.collect::<Vec<Group>>(),
|
||||
),
|
||||
new_group_form_refill,
|
||||
},
|
||||
)
|
||||
.unwrap_or_else(|e| e.to_string()),
|
||||
)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
}
|
||||
serve_admin(
|
||||
req,
|
||||
config,
|
||||
templates,
|
||||
dbs,
|
||||
&[ErrorTemplate {
|
||||
text: "Le groupe demandé n'existe pas.",
|
||||
}],
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
serve_admin(req, config, templates, dbs, &[]).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_admin<'a>(
|
||||
req: tide::Request<()>,
|
||||
config: Arc<Config>,
|
||||
|
@ -173,11 +387,12 @@ async fn serve_admin<'a>(
|
|||
errors,
|
||||
},
|
||||
ads: &to_json(
|
||||
dbs.ads
|
||||
.iter()
|
||||
dbs.ad_by_group
|
||||
.scan_prefix(ROOT_GROUP_ID)
|
||||
.filter_map(|x| {
|
||||
let (ad_id, ad) = x.ok()?;
|
||||
let ad_id = hex::encode(ad_id.as_ref());
|
||||
let (k, ad) = x.ok()?;
|
||||
let ad_id =
|
||||
hex::encode(AdId::try_from(&k.as_ref()[16..32]).ok()?);
|
||||
Some(AdWithId {
|
||||
ad: bincode::deserialize::<Ad>(&ad).ok()?,
|
||||
selected: selected_ad.map_or(false, |i| i == ad_id),
|
||||
|
@ -186,6 +401,16 @@ async fn serve_admin<'a>(
|
|||
})
|
||||
.collect::<Vec<AdWithId>>(),
|
||||
),
|
||||
groups: &to_json(
|
||||
dbs.group_by_group
|
||||
.scan_prefix(ROOT_GROUP_ID)
|
||||
.keys()
|
||||
.filter_map(|k| GroupId::try_from(&k.ok()?.as_ref()[16..32]).ok())
|
||||
.filter_map(|subgroup_id| {
|
||||
bincode::deserialize(&dbs.group.get(subgroup_id).ok()??).ok()
|
||||
})
|
||||
.collect::<Vec<Group>>(),
|
||||
),
|
||||
},
|
||||
)
|
||||
.unwrap_or_else(|e| e.to_string()),
|
||||
|
@ -226,11 +451,11 @@ async fn handle_post_index(
|
|||
templates: Arc<Templates<'static>>,
|
||||
dbs: Dbs,
|
||||
) -> tide::Result<tide::Response> {
|
||||
match req.body_form::<Query>().await? {
|
||||
Query::NewAdQuery(query) => {
|
||||
match req.body_form::<IndexQuery>().await? {
|
||||
IndexQuery::NewAd(query) => {
|
||||
handle_new_ad(req, config.clone(), templates.clone(), dbs.clone(), query).await
|
||||
}
|
||||
Query::RmAdQuery(query) => {
|
||||
IndexQuery::RmAd(query) => {
|
||||
handle_rm_ad(req, config.clone(), templates.clone(), dbs.clone(), query).await
|
||||
}
|
||||
}
|
||||
|
@ -243,9 +468,7 @@ async fn handle_new_ad(
|
|||
dbs: Dbs,
|
||||
query: NewAdQuery,
|
||||
) -> tide::Result<tide::Response> {
|
||||
let mut hasher = Sha512Trunc256::new();
|
||||
hasher.update(&query.psw);
|
||||
dbs.ads
|
||||
dbs.ad
|
||||
.insert(
|
||||
AdId::random(),
|
||||
bincode::serialize(&Ad {
|
||||
|
@ -275,7 +498,10 @@ async fn handle_new_ad(
|
|||
})
|
||||
},
|
||||
author: query.author,
|
||||
password: hasher.finalize()[..].try_into().unwrap(),
|
||||
password: Argon2::default()
|
||||
.hash_password(query.psw.as_bytes(), &SaltString::generate(&mut OsRng))
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
price: query.price,
|
||||
quantity: query.quantity,
|
||||
time: 0,
|
||||
|
@ -284,7 +510,7 @@ async fn handle_new_ad(
|
|||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
dbs.ads.flush_async().await.unwrap();
|
||||
dbs.ad.flush_async().await.unwrap();
|
||||
Ok(tide::Redirect::new(&config.root_url).into())
|
||||
}
|
||||
|
||||
|
@ -295,30 +521,18 @@ async fn handle_rm_ad(
|
|||
dbs: Dbs,
|
||||
query: RmAdQuery,
|
||||
) -> tide::Result<tide::Response> {
|
||||
let mut hasher = Sha512Trunc256::new();
|
||||
hasher.update(query.psw);
|
||||
let password: PasswordHash = hasher.finalize()[..].try_into().unwrap();
|
||||
/*query
|
||||
.ads
|
||||
.into_iter()
|
||||
.filter_map(|x| Some(AdId::try_from(hex::decode(x).ok()?.as_ref()).ok()?))
|
||||
.for_each(|ad_id| {
|
||||
if let Some(raw) = dbs.ads.get(&ad_id).unwrap() {
|
||||
if let Ok(ad) = bincode::deserialize::<Ad>(&raw) {
|
||||
if ad.password == password {
|
||||
dbs.ads.remove(&ad_id).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
});*/
|
||||
if let Ok(ad_id) = hex::decode(query.ad) {
|
||||
if let Ok(ad_id) = AdId::try_from(ad_id.as_ref()) {
|
||||
if let Some(raw) = dbs.ads.get(&ad_id).unwrap() {
|
||||
if let Some(raw) = dbs.ad.get(&ad_id).unwrap() {
|
||||
if let Ok(ad) = bincode::deserialize::<Ad>(&raw) {
|
||||
if ad.password == password {
|
||||
dbs.ads.remove(&ad_id).unwrap();
|
||||
if let Ok(password) = PasswordHash::new(&ad.password) {
|
||||
if Argon2::default()
|
||||
.verify_password(query.psw.as_bytes(), &password)
|
||||
.is_ok()
|
||||
{
|
||||
dbs.ad.remove(&ad_id).unwrap();
|
||||
|
||||
dbs.ads.flush_async().await.unwrap();
|
||||
dbs.ad.flush_async().await.unwrap();
|
||||
} else {
|
||||
return serve_index(
|
||||
req,
|
||||
|
@ -336,6 +550,7 @@ async fn handle_rm_ad(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(tide::Redirect::new(&config.root_url).into())
|
||||
}
|
||||
|
||||
|
@ -364,6 +579,31 @@ async fn handle_admin(
|
|||
}
|
||||
}
|
||||
|
||||
async fn handle_admin_group(
|
||||
req: tide::Request<()>,
|
||||
config: Arc<Config>,
|
||||
templates: Arc<Templates<'static>>,
|
||||
dbs: Dbs,
|
||||
) -> tide::Result<tide::Response> {
|
||||
if let Some(psw) = req.cookie("admin") {
|
||||
if config.admin_passwords.contains(&String::from(psw.value())) {
|
||||
serve_admin_group(req, config, templates, dbs, &[], None).await
|
||||
} else {
|
||||
serve_admin_login(
|
||||
req,
|
||||
config,
|
||||
templates,
|
||||
&[ErrorTemplate {
|
||||
text: "Mot de passe administrateur invalide",
|
||||
}],
|
||||
)
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
serve_admin_login(req, config, templates, &[]).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_post_admin(
|
||||
mut req: tide::Request<()>,
|
||||
config: Arc<Config>,
|
||||
|
@ -373,16 +613,58 @@ async fn handle_post_admin(
|
|||
if let Some(psw) = req.cookie("admin") {
|
||||
if config.admin_passwords.contains(&String::from(psw.value())) {
|
||||
match req.body_form::<AdminQuery>().await? {
|
||||
AdminQuery::RmAdQuery(query) => {
|
||||
AdminQuery::RmAd(query) => {
|
||||
if let Ok(ad_id) = hex::decode(query.ad) {
|
||||
if let Ok(ad_id) = AdId::try_from(ad_id.as_ref()) {
|
||||
dbs.ads.remove(&ad_id).unwrap();
|
||||
dbs.ad.remove(&ad_id).unwrap();
|
||||
|
||||
dbs.ads.flush_async().await.unwrap();
|
||||
dbs.ad.flush_async().await.unwrap();
|
||||
}
|
||||
}
|
||||
Ok(tide::Redirect::new(&format!("{}admin", config.root_url)).into())
|
||||
}
|
||||
AdminQuery::NewGroup(query) => {
|
||||
if let Some(Ok(parent_group_id)) = if query.parent.is_empty() {
|
||||
Some(Ok(ROOT_GROUP_ID))
|
||||
} else {
|
||||
dbs.group_by_name
|
||||
.get(&query.parent)
|
||||
.unwrap()
|
||||
.map(|o| GroupId::try_from(o.as_ref()))
|
||||
} {
|
||||
if !dbs.group_by_name.contains_key(&query.name).unwrap() {
|
||||
let group_id = rand::random::<GroupId>();
|
||||
dbs.group
|
||||
.insert(
|
||||
group_id,
|
||||
bincode::serialize(&Group {
|
||||
parent: parent_group_id,
|
||||
name: query.name.clone(),
|
||||
title: query.title,
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
dbs.group_by_name
|
||||
.insert(query.name.clone(), &group_id)
|
||||
.unwrap();
|
||||
dbs.group_by_group
|
||||
.insert([parent_group_id, group_id].concat(), &[])
|
||||
.unwrap();
|
||||
return Ok(tide::Redirect::new(&format!(
|
||||
"{}admin/g/{}",
|
||||
config.root_url, query.name
|
||||
))
|
||||
.into());
|
||||
}
|
||||
return Ok(tide::Redirect::new(&format!(
|
||||
"{}admin/g/{}",
|
||||
config.root_url, query.parent
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Ok(tide::Redirect::new(&format!("{}admin", config.root_url)).into())
|
||||
}
|
||||
_ => serve_admin(req, config, templates, dbs, &[]).await,
|
||||
}
|
||||
} else {
|
||||
|
@ -396,7 +678,7 @@ async fn handle_post_admin(
|
|||
)
|
||||
.await
|
||||
}
|
||||
} else if let AdminQuery::LoginQuery(query) = req.body_form::<AdminQuery>().await? {
|
||||
} else if let AdminQuery::Login(query) = req.body_form::<AdminQuery>().await? {
|
||||
if config.admin_passwords.contains(&query.psw) {
|
||||
serve_admin(req, config.clone(), templates, dbs, &[])
|
||||
.await
|
||||
|
|
|
@ -7,12 +7,17 @@ use std::path::Path;
|
|||
|
||||
const TEMPLATES_DIR: &str = "templates";
|
||||
static TEMPLATE_FILES: &[(&str, &str)] = &[
|
||||
("index.html", include_str!("../templates/index.html")),
|
||||
("admin.html", include_str!("../templates/admin.html")),
|
||||
(
|
||||
"admin_group.html",
|
||||
include_str!("../templates/admin_group.html"),
|
||||
),
|
||||
(
|
||||
"admin_login.html",
|
||||
include_str!("../templates/admin_login.html"),
|
||||
),
|
||||
("group.html", include_str!("../templates/group.html")),
|
||||
("index.html", include_str!("../templates/index.html")),
|
||||
];
|
||||
|
||||
pub struct Templates<'reg> {
|
||||
|
@ -59,6 +64,7 @@ pub struct IndexTemplate<'a> {
|
|||
#[serde(flatten)]
|
||||
pub common: CommonTemplate<'a>,
|
||||
pub ads: &'a Json,
|
||||
pub groups: &'a Json,
|
||||
pub new_ad_form_refill: Option<NewAdQuery>,
|
||||
}
|
||||
|
||||
|
@ -73,6 +79,27 @@ pub struct AdminTemplate<'a> {
|
|||
#[serde(flatten)]
|
||||
pub common: CommonTemplate<'a>,
|
||||
pub ads: &'a Json,
|
||||
pub groups: &'a Json,
|
||||
}
|
||||
|
||||
// TODO add subgroups, parent groups, title
|
||||
#[derive(Serialize)]
|
||||
pub struct GroupTemplate<'a> {
|
||||
#[serde(flatten)]
|
||||
pub common: CommonTemplate<'a>,
|
||||
pub ads: &'a Json,
|
||||
pub groups: &'a Json,
|
||||
pub new_ad_form_refill: Option<NewAdQuery>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AdminGroupTemplate<'a> {
|
||||
#[serde(flatten)]
|
||||
pub common: CommonTemplate<'a>,
|
||||
pub ads: &'a Json,
|
||||
pub group: &'a Json,
|
||||
pub groups: &'a Json,
|
||||
pub new_group_form_refill: Option<AdminNewGroupQuery>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
13
src/utils.rs
13
src/utils.rs
|
@ -2,7 +2,16 @@ use serde::{Deserialize, Serialize};
|
|||
use sha2::{Digest, Sha256};
|
||||
use std::convert::{TryFrom, TryInto};
|
||||
|
||||
pub type PasswordHash = [u8; 32];
|
||||
pub type GroupId = [u8; 16];
|
||||
|
||||
pub static ROOT_GROUP_ID: GroupId = [0; 16];
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct Group {
|
||||
pub name: String,
|
||||
pub parent: GroupId,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize)]
|
||||
pub struct AdId([u8; 16]);
|
||||
|
@ -30,7 +39,7 @@ impl TryFrom<&[u8]> for AdId {
|
|||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Ad {
|
||||
pub author: String,
|
||||
pub password: PasswordHash,
|
||||
pub password: String,
|
||||
pub price: String,
|
||||
pub pubkey: Option<String>,
|
||||
pub quantity: String,
|
||||
|
|
|
@ -27,6 +27,10 @@
|
|||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#each groups}}
|
||||
<a href="{{root_url}}admin/g/{{this.name}}">{{this.title}}</a><br/>
|
||||
{{/each}}
|
||||
|
||||
{{#if ads}}
|
||||
<span>Cliquez sur une annonce pour afficher le détail.</span>
|
||||
<form method="post">
|
||||
|
@ -62,6 +66,19 @@
|
|||
{{else}}
|
||||
<p>Il n'y a pas encore d'annonce ici.</p>
|
||||
{{/if}}
|
||||
|
||||
<form method="post" action="{{root_url}}admin">
|
||||
<input type="hidden" name="a" value="new_group" autocomplete="off"/>
|
||||
<input type="hidden" name="parent" value="" autocomplete="off"/>
|
||||
<fieldset>
|
||||
<legend>Nouveau groupe</legend>
|
||||
<label for="f_new_title">Titre :</label>
|
||||
<input type="text" id="f_new_title" name="title" placeholder="Marché de Juneville"{{#if new_group_form_refill}} value="{{new_group_form_refill.title}}"{{/if}} required/><br/>
|
||||
<label for="f_new_name">Identifiant (utilisé dans les URL, <code>[-_.a-zA-Z0-9]{1,64}</code>) :</label>
|
||||
<input type="text" id="f_new_name" name="name" placeholder="juneville" maxlength="64"{{#if new_group_form_refill}} value="{{new_group_form_refill.name}}"{{/if}} required/><br/>
|
||||
<input type="submit" value="Créer"/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
|
@ -70,7 +87,7 @@
|
|||
<p><a href="https://forum.duniter.org">Toutes les questions techniques ont leur place sur le forum.</a></p>
|
||||
|
||||
<p><a href="https://git.p2p.legal/tuxmain/gmarche-rs">Code source</a> sous licence <a href="https://www.gnu.org/licenses/licenses.html#AGPL">GNU AGPL v3</a>. 🦀 Écrit en <a href="https://www.rust-lang.org">Rust</a>. Images de Attilax.<br/>
|
||||
CopyLeft 2020 Pascal Engélibert<br/>
|
||||
CopyLeft 2020-2022 Pascal Engélibert<br/>
|
||||
Image de fond : Claudia Peters, FreeImages.com</p>
|
||||
|
||||
<p><a href="{{root_url}}">Accueil</a> – <a href="{{root_url}}admin/logout">Verrouiller</a></p>
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
<!doctype html>
|
||||
<html lang="{{lang}}">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>{{group.title}} | Administration | {{title}}</title>
|
||||
<link rel="stylesheet" href="{{root_url}}static/style1.css"/>
|
||||
<link rel="shortcut icon" href="{{root_url}}static/icon.png"/>
|
||||
<script type="text/javascript" src="{{root_url}}static/script1.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="center page">
|
||||
<header>
|
||||
<a href="{{root_url}}"><img id="banner" alt="Bannière {{title}}" src="{{root_url}}static/banner.jpg"/></a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h1>{{group.title}} – Administration – {{title}}</h1>
|
||||
|
||||
{{#if errors}}
|
||||
<div id="errors">
|
||||
<span>Oups, il y a un problème :</span>
|
||||
<ul>
|
||||
{{#each errors}}
|
||||
<li>{{this.text}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#each groups}}
|
||||
<a href="{{root_url}}admin/g/{{this.name}}">{{this.title}}</a><br/>
|
||||
{{/each}}
|
||||
|
||||
{{#if ads}}
|
||||
<span>Cliquez sur une annonce pour afficher le détail.</span>
|
||||
<form method="post">
|
||||
<table id="ads">
|
||||
<thead>
|
||||
<tr><td></td><th>Annonce</th><th>Quantité</th><th>Vendeur</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each ads}}
|
||||
<tr>
|
||||
<td><input type="radio" name="ad" value="{{this.id}}" aria-label="Sélectionner l'annonce" required/></td>
|
||||
<td onclick="ad_detail(event,'{{this.id}}')" title="Afficher le détail"><a href="{{../root_url}}admin/ad/{{this.id}}">{{this.ad.title}}</a></td>
|
||||
<td>{{this.ad.quantity}}</td>
|
||||
<td>{{this.ad.author}}</td>
|
||||
</tr>
|
||||
<tr id="ad-detail-{{this.id}}" class="ad-detail{{#unless this.selected}} ad-detail-no{{/unless}}">
|
||||
<td colspan="4"><p>
|
||||
{{#if this.ad.pubkey}}Clé publique : {{this.ad.pubkey}}<br/>{{/if}}
|
||||
Prix : {{this.ad.price}}
|
||||
</p></td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
<br/>
|
||||
|
||||
<fieldset>
|
||||
<legend>Supprimer l'annonce sélectionnée</legend>
|
||||
<button type="submit" name="a" value="rm_ad">Supprimer</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<br/>
|
||||
{{else}}
|
||||
<p>Il n'y a pas encore d'annonce ici.</p>
|
||||
{{/if}}
|
||||
|
||||
<form method="post" action="{{root_url}}admin">
|
||||
<input type="hidden" name="a" value="new_group" autocomplete="off"/>
|
||||
<input type="hidden" name="parent" value="{{group.name}}" autocomplete="off"/>
|
||||
<fieldset>
|
||||
<legend>Nouveau groupe</legend>
|
||||
<label for="f_new_title">Titre :</label>
|
||||
<input type="text" id="f_new_title" name="title" placeholder="Marché de Juneville"{{#if new_group_form_refill}} value="{{new_group_form_refill.title}}"{{/if}} required/><br/>
|
||||
<label for="f_new_name">Identifiant (utilisé dans les URL, <code>[-_.a-zA-Z0-9]{1,64}</code>) :</label>
|
||||
<input type="text" id="f_new_name" name="name" placeholder="juneville" maxlength="64"{{#if new_group_form_refill}} value="{{new_group_form_refill.name}}"{{/if}} required/><br/>
|
||||
<input type="submit" value="Créer"/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<hr style="clear: both;"/>
|
||||
|
||||
<p><a href="https://forum.duniter.org">Toutes les questions techniques ont leur place sur le forum.</a></p>
|
||||
|
||||
<p><a href="https://git.p2p.legal/tuxmain/gmarche-rs">Code source</a> sous licence <a href="https://www.gnu.org/licenses/licenses.html#AGPL">GNU AGPL v3</a>. 🦀 Écrit en <a href="https://www.rust-lang.org">Rust</a>. Images de Attilax.<br/>
|
||||
CopyLeft 2020-2022 Pascal Engélibert<br/>
|
||||
Image de fond : Claudia Peters, FreeImages.com</p>
|
||||
|
||||
<p><a href="{{root_url}}">Accueil</a> – <a href="{{root_url}}admin/logout">Verrouiller</a></p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -42,7 +42,7 @@
|
|||
<p><a href="https://forum.duniter.org">Toutes les questions techniques ont leur place sur le forum.</a></p>
|
||||
|
||||
<p><a href="https://git.p2p.legal/tuxmain/gmarche-rs">Code source</a> sous licence <a href="https://www.gnu.org/licenses/licenses.html#AGPL">GNU AGPL v3</a>. 🦀 Écrit en <a href="https://www.rust-lang.org">Rust</a>. Images de Attilax.<br/>
|
||||
CopyLeft 2020 Pascal Engélibert<br/>
|
||||
CopyLeft 2020-2022 Pascal Engélibert<br/>
|
||||
Image de fond : Claudia Peters, FreeImages.com</p>
|
||||
|
||||
<p><a href="{{root_url}}">Accueil</a></p>
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
<!doctype html>
|
||||
<html lang="{{lang}}">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>{{group-title}} | {{title}}</title>
|
||||
<link rel="stylesheet" href="{{root_url}}static/style1.css"/>
|
||||
<link rel="shortcut icon" href="{{root_url}}static/icon.png"/>
|
||||
<script type="text/javascript" src="{{root_url}}static/script1.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="center page">
|
||||
<header>
|
||||
<a href="{{root_url}}"><img id="banner" alt="Bannière {{title}}" src="{{root_url}}static/banner.jpg"/></a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h1>{{title}}</h1>
|
||||
|
||||
{{#if errors}}
|
||||
<div id="errors">
|
||||
<span>Oups, il y a un problème :</span>
|
||||
<ul>
|
||||
{{#each errors}}
|
||||
<li>{{this.text}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#each groups}}
|
||||
<a href="{{root_url}}g/{{this.name}}">{{this.title}}</a><br/>
|
||||
{{/each}}
|
||||
|
||||
{{#if ads}}
|
||||
<span>Cliquez sur une annonce pour afficher le détail.</span>
|
||||
<form method="post">
|
||||
<table id="ads">
|
||||
<thead>
|
||||
<tr><td></td><th>Annonce</th><th>Quantité</th><th>Vendeur</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each ads}}
|
||||
<tr>
|
||||
<td><input type="radio" name="ad" value="{{this.id}}" aria-label="Sélectionner l'annonce" required/></td>
|
||||
<td onclick="ad_detail(event,'{{this.id}}')" title="Afficher le détail"><a href="{{../root_url}}ad/{{this.id}}">{{this.ad.title}}</a></td>
|
||||
<td>{{this.ad.quantity}}</td>
|
||||
<td>{{this.ad.author}}</td>
|
||||
</tr>
|
||||
<tr id="ad-detail-{{this.id}}" class="ad-detail{{#unless this.selected}} ad-detail-no{{/unless}}">
|
||||
<td colspan="4"><p>
|
||||
{{#if this.ad.pubkey}}Clé publique : {{this.ad.pubkey}}<br/>{{/if}}
|
||||
Prix : {{this.ad.price}}
|
||||
</p></td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
<br/>
|
||||
|
||||
<fieldset>
|
||||
<legend>Supprimer l'annonce sélectionnée</legend>
|
||||
<label for="f_rm_psw">Mot de passe :</label>
|
||||
<input type="password" id="f_rm_psw" name="psw"/><br/>
|
||||
<button type="submit" name="a" value="rm_ad">Supprimer</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<br/>
|
||||
{{else}}
|
||||
<p>Il n'y a pas encore d'annonce ici.</p>
|
||||
{{/if}}
|
||||
|
||||
<img id="stand" alt="Marché" src="{{root_url}}static/standgm.png"/>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="a" value="new_ad" autocomplete="off"/>
|
||||
<fieldset>
|
||||
<legend>Nouvelle annonce</legend>
|
||||
<label for="f_new_title">Titre :</label>
|
||||
<input type="text" id="f_new_title" name="title" placeholder="Carottes"{{#if new_ad_form_refill}} value="{{new_ad_form_refill.title}}"{{/if}} required/><br/>
|
||||
<label for="f_new_quantity">Quantité :</label>
|
||||
<input type="text" id="f_new_quantity" name="quantity" placeholder="10 kg"{{#if new_ad_form_refill}} value="{{new_ad_form_refill.quantity}}"{{/if}}/><br/>
|
||||
<label for="f_new_price">Prix :</label>
|
||||
<input type="text" id="f_new_price" name="price" placeholder="42 DU/kg"{{#if new_ad_form_refill}} value="{{new_ad_form_refill.price}}"{{/if}}/><br/>
|
||||
<label for="f_new_author">Votre nom :</label>
|
||||
<input type="text" id="f_new_author" name="author" placeholder="Toto"{{#if new_ad_form_refill}} value="{{new_ad_form_refill.author}}"{{/if}} required/><br/>
|
||||
<label for="f_new_pubkey">Clé publique :</label>
|
||||
<input type="text" id="f_new_pubkey" name="pubkey"{{#if new_ad_form_refill}} value="{{new_ad_form_refill.pubkey}}"{{/if}}/><br/>
|
||||
<label for="f_new_psw">Mot de passe :</label>
|
||||
<input type="text" id="f_new_psw" name="psw"{{#if new_ad_form_refill}} value="{{new_ad_form_refill.psw}}"{{/if}}/><br/>
|
||||
<span>Le mot de passe sera demandé pour modifier ou supprimer l'annonce.</span><br/>
|
||||
<input type="submit" value="Publier"/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<hr style="clear: both;"/>
|
||||
|
||||
<p><a href="https://forum.duniter.org">Toutes les questions techniques ont leur place sur le forum.</a></p>
|
||||
|
||||
<p><a href="https://git.p2p.legal/tuxmain/gmarche-rs">Code source</a> sous licence <a href="https://www.gnu.org/licenses/licenses.html#AGPL">GNU AGPL v3</a>. 🦀 Écrit en <a href="https://www.rust-lang.org">Rust</a>. Images de Attilax.<br/>
|
||||
CopyLeft 2020-2022 Pascal Engélibert<br/>
|
||||
Image de fond : Claudia Peters, FreeImages.com</p>
|
||||
|
||||
<p><a href="{{root_url}}admin">Administration</a></p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -29,6 +29,10 @@
|
|||
|
||||
<p>Ceci est une démo du nouveau site ĞMarché en développement.</p>
|
||||
|
||||
{{#each groups}}
|
||||
<a href="{{root_url}}g/{{this.name}}">{{this.title}}</a><br/>
|
||||
{{/each}}
|
||||
|
||||
{{#if ads}}
|
||||
<span>Cliquez sur une annonce pour afficher le détail.</span>
|
||||
<form method="post">
|
||||
|
@ -97,7 +101,7 @@
|
|||
<p><a href="https://forum.duniter.org">Toutes les questions techniques ont leur place sur le forum.</a></p>
|
||||
|
||||
<p><a href="https://git.p2p.legal/tuxmain/gmarche-rs">Code source</a> sous licence <a href="https://www.gnu.org/licenses/licenses.html#AGPL">GNU AGPL v3</a>. 🦀 Écrit en <a href="https://www.rust-lang.org">Rust</a>. Images de Attilax.<br/>
|
||||
CopyLeft 2020 Pascal Engélibert<br/>
|
||||
CopyLeft 2020-2022 Pascal Engélibert<br/>
|
||||
Image de fond : Claudia Peters, FreeImages.com</p>
|
||||
|
||||
<p><a href="{{root_url}}admin">Administration</a></p>
|
||||
|
|
Loading…
Reference in New Issue