Commit a9e54f35 authored by Robert Czechowski's avatar Robert Czechowski

Merge branch 'v1.6' into deploy

parents 837a494b 68d2b546
Pipeline #915 passed with stages
in 10 minutes and 27 seconds
......@@ -4,4 +4,16 @@ config.json
**/*.rs.bk
*~
*.db
/export
/gitstats
/tasks/*
**/.directory
**/#*#
/.idea
*.pdf
*.json
*.yaml
*.sql
*.sqlite
*.csv
/bulma
......@@ -1064,7 +1064,7 @@ checksum = "79c56d6a0b07f9e19282511c83fc5b086364cbae4ba8c7d5f190c3d9b0425a48"
[[package]]
name = "medal"
version = "1.5.3"
version = "1.6.0"
dependencies = [
"bcrypt",
"csv",
......
[package]
version = "1.5.3"
version = "1.6.0"
name = "medal"
authors = ["Robert Czechowski <czechowski@bwinf.de>", "Daniel Brüning <bruening@bwinf.de>"]
......
# Configuration
* `database_file`:
* `host`
* `port`:
* `template`:
* `self_url`:
* `cookie_signing_secret`:
* `require_sex`:
* `allow_sex_na`:
* `allow_sex_diverse`:
* `allow_sex_other`:
* `server_message`:
* `oauth_providers`:
\ No newline at end of file
# The Medal Contest Platform
# The Medal Contest Platform ![Logo](static/images/medal_logo_small.png)
[![crates.io](https://img.shields.io/crates/v/medal?color=orange)](https://crates.io/crates/medal)
[![documentation](https://img.shields.io/crates/v/medal?label=docs)](https://jim.test.bwinf.de/doc/medal/)
......@@ -52,15 +52,15 @@ The `config.json` configures the plattform (see src/config.rs).
Needs `rustc` and `cargo` 1.40 (stable) or higher.
Rust can be obtained here: https://rustup.rs/
Rust can be obtained here: https://rustup.rs/
Running
Running
```
make
```
compiles and runs a debug-/test-server.
For production use, a release binary should be compiled and served behind a reverse proxy (nginx, apache, …).
For production use, a release binary should be compiled and served behind a reverse proxy (nginx, apache, …).
```
make release
```
......@@ -81,7 +81,7 @@ upstream medal {
server {
# Other server settings here
location ~* \.(yaml)$ {
deny all;
}
......@@ -92,7 +92,7 @@ server {
location /tasks {
add_header Cache-Control "public, max-age=604800";
}
}
location / {
proxy_pass http://medal;
......@@ -105,13 +105,13 @@ The following configuration can be used for an Apache 2.4 webserver:
ServerSignature Off
ProxyPreserveHost On
AllowEncodedSlashes NoDecode
ProxyPass /static/ !
ProxyPass /tasks/ !
ProxyPass /favicon.ico !
ProxyPass / http://[::1]:8080/
ProxyPassReverse / http://[::1]:8080/
Alias "/tasks/" "/path/to/medal/tasks/"
Alias "/static/" "/path/to/medal/static/"
Alias "/favicon.ico" "/path/to/medal/static/images/favicon.png"
......@@ -119,7 +119,7 @@ The following configuration can be used for an Apache 2.4 webserver:
<filesMatch "\.(css|jpe?g|png|gif|js|ico)$">
Header set Cache-Control "max-age=604800, public"
</filesMatch>
<FilesMatch "\.yaml$">
Deny from all
</FilesMatch>
......@@ -127,7 +127,7 @@ The following configuration can be used for an Apache 2.4 webserver:
<Directory "/path/to/medal/static/">
Require all granted
</Directory>
<Directory "/path/to/medal/tasks/">
Require all granted
</Directory>
......@@ -139,7 +139,7 @@ The following configuration can be used for an Apache 2.4 webserver:
Please format your code with `rustfmt` and check it for warnings with `clippy`.
You can install those with
You can install those with
```
rustup component add rustfmt --toolchain nightly
rustup component add clippy
......
{
"name": "JwInf 2018 Runde 1",
"participationStart": "2009-01-01T12:00:00+01:00",
"participationEnd": "2009-01-01T12:00:00+01:00",
"durationMinutes": 45,
"publicListing": true,
"tasks": {
"Aufgabe 1": "tasks/task1",
"Aufgabe 2": ["tasks/task2a", "tasks/task2b", "tasks/task3"]
"Aufgabe 3": {
"tasks/task4a": {"stars": 1},
"tasks/task4b": {"stars": 3},
"tasks/task3": {"stars": 5}
}
}
}
language: rust
rust:
- stable
- nightly
script:
- cargo build
- cargo test
- cargo build --features=redis-backend
- cargo test --features=redis-backend
cache:
directories:
- $HOME/.cargo
- target
- local
......@@ -32,8 +32,12 @@ pub struct Config {
pub cookie_signing_secret: Option<String>,
pub disable_results_page: Option<bool>,
pub enable_password_login: Option<bool>,
pub server_message: Option<String>,
pub teacher_page: Option<String>,
pub require_sex: Option<bool>,
pub allow_sex_na: Option<bool>,
pub allow_sex_diverse: Option<bool>,
pub allow_sex_other: Option<bool>,
pub dbstatus_secret: Option<String>,
pub template_params: Option<::std::collections::BTreeMap<String, String>>,
}
#[derive(StructOpt, Debug)]
......
This diff is collapsed.
......@@ -175,6 +175,8 @@ impl MedalObject<Connection> for Contest {
}
impl MedalConnection for Connection {
fn reconnect(config: &config::Config) -> Self { Self::reconnect_concrete(config) }
fn dbtype(&self) -> &'static str { "postgres" }
fn migration_already_applied(&self, name: &str) -> bool {
......@@ -308,16 +310,21 @@ impl MedalConnection for Connection {
SessionUser::minimal(id, session_token.to_owned(), csrf_token)
}
fn get_session_or_new(&self, key: &str) -> SessionUser {
let query = "UPDATE session
SET session_token = $1
WHERE session_token = $2";
self.get_session(&key).ensure_alive().unwrap_or_else(|| {
// TODO: Factor this out in own function
// TODO: Should a new session key be generated every time?
self.execute(query, &[&Option::<String>::None, &key]).unwrap();
self.new_session(&key)
})
fn get_session_or_new(&self, key: &str) -> Result<SessionUser, ()> {
fn disable_old_session_and_create_new(conn: &Connection, key: &str) -> Result<SessionUser, ()> {
let query = "UPDATE session
SET session_token = $1
WHERE session_token = $2";
// TODO: Should a new session key be generated every time?
conn.execute(query, &[&Option::<String>::None, &key]).map_err(|_| ())?;
Ok(conn.new_session(&key))
}
if let Some(session) = self.get_session(&key).ensure_alive() {
Ok(session)
} else {
disable_old_session_and_create_new(self, key)
}
}
fn get_user_by_id(&self, user_id: i32) -> Option<SessionUser> {
......@@ -579,7 +586,7 @@ impl MedalConnection for Connection {
fn signup(&self, session_token: &str, username: &str, email: &str, password_hash: String, salt: &str)
-> SignupResult {
let mut session_user = self.get_session_or_new(&session_token);
let mut session_user = self.get_session_or_new(&session_token).unwrap();
if session_user.is_logged_in() {
return SignupResult::UserLoggedIn;
......@@ -874,6 +881,7 @@ impl MedalConnection for Connection {
"teacher_firstname",
"teacher_lastname",
"teacher_oauth_foreign_id",
"teacher_oauth_school_id",
"teacher_oauth_provider",
"contest_id",
"start_date"];
......@@ -934,6 +942,17 @@ impl MedalConnection for Connection {
for i in 20..20 + taskgroups.len() {
points.push(row.get::<_, Option<i32>>(i));
}
let teacher_oauth_and_school_id = row.get::<_, Option<String>>(15);
let (teacher_oauth_id, teacher_school_id) = if let Some(toasi) = teacher_oauth_and_school_id {
let mut v = toasi.split('/');
let oid: Option<String> = v.next().map(|s| s.to_owned());
let sid: Option<String> = v.next().map(|s| s.to_owned());
(oid, sid)
} else {
(None, None)
};
// Serialized as several tuples because Serde only supports tuples up to a certain length
// (16 according to https://docs.serde.rs/serde/trait.Deserialize.html)
wtr.serialize(((row.get::<_, i32>(0),
......@@ -952,7 +971,8 @@ impl MedalConnection for Connection {
row.get::<_, Option<i32>>(13),
row.get::<_, Option<String>>(14),
row.get::<_, Option<String>>(15),
row.get::<_, Option<String>>(16),
teacher_oauth_id,
teacher_school_id,
row.get::<_, Option<String>>(17)),
row.get::<_, Option<i32>>(18),
row.get::<_, Option<self::time::Timespec>>(19)
......@@ -1416,13 +1436,14 @@ impl MedalConnection for Connection {
let query = "SELECT id, firstname, lastname
FROM session
WHERE oauth_foreign_id = $1
OR oauth_foreign_id LIKE $2
LIMIT 30";
Ok(self.query_map_many(query, &[&pms_id], |row| (row.get(0), row.get(1), row.get(2))).unwrap())
Ok(self.query_map_many(query, &[&pms_id, &format!("{}/%", pms_id)], |row| (row.get(0), row.get(1), row.get(2))).unwrap())
} else if let (Some(firstname), Some(lastname)) = (s_firstname, s_lastname) {
let query = "SELECT id, firstname, lastname
FROM session
WHERE firstname LIKE $1
AND lastname LIKE $2
WHERE firstname ILIKE $1
AND lastname ILIKE $2
LIMIT 30";
Ok(self.query_map_many(query, &[&firstname, &lastname], |row| (row.get(0), row.get(1), row.get(2)))
.unwrap())
......
......@@ -12,6 +12,7 @@
* You should have received a copy of the GNU Affero General Public License along with this program. If not, see *
\* <http://www.gnu.org/licenses/>. */
use config;
use db_objects::*;
#[derive(Debug)]
......@@ -26,6 +27,8 @@ pub enum SignupResult {
/// This trait abstracts the database connection and provides function for all actions to be performed on the database
/// in the medal platform.
pub trait MedalConnection {
fn reconnect(&config::Config) -> Self;
fn dbtype(&self) -> &'static str;
fn migration_already_applied(&self, name: &str) -> bool;
......@@ -44,7 +47,9 @@ pub trait MedalConnection {
/// Saves the session data of `session` in the database.
fn save_session(&self, session: SessionUser);
/// Combination of [`get_session`](#tymethod.get_session) and [`new_session`](#tymethod.new_session).
fn get_session_or_new(&self, key: &str) -> SessionUser;
///
/// This method can still fail in case of database error in order to bubble them up to the webframework
fn get_session_or_new(&self, key: &str) -> Result<SessionUser, ()>;
/// Try to get session associated to the id `user_id`.
///
......
......@@ -16,6 +16,7 @@
extern crate postgres;
use config;
use postgres::Connection;
use time;
use time::Duration;
......@@ -34,6 +35,8 @@ trait Queryable {
where F: FnMut(postgres::rows::Row<'_>) -> T;
fn exists(&self, sql: &str, params: &[&dyn postgres::types::ToSql]) -> bool;
fn get_last_id(&self) -> Option<i32>;
fn reconnect_concrete(&config::Config) -> Self;
}
impl Queryable for Connection {
......@@ -62,6 +65,10 @@ impl Queryable for Connection {
})
}
// Empty line intended
fn reconnect_concrete(config: &config::Config) -> Self {
postgres::Connection::connect(config.database_url.clone().unwrap(), postgres::TlsMode::None).unwrap()
}
}
impl MedalObject<Connection> for Submission {
......
......@@ -29,6 +29,7 @@
extern crate postgres;
use config;
use postgres::Connection;
use time;
use time::Duration;
......@@ -47,6 +48,8 @@ trait Queryable {
where F: FnMut(postgres::rows::Row<'_>) -> T;
fn exists(&self, sql: &str, params: &[&dyn postgres::types::ToSql]) -> bool;
fn get_last_id(&self) -> Option<i32>;
fn reconnect_concrete(&config::Config) -> Self;
}
impl Queryable for Connection {
......@@ -75,6 +78,10 @@ impl Queryable for Connection {
})
}
// Empty line intended
fn reconnect_concrete(config: &config::Config) -> Self {
postgres::Connection::connect(config.database_url.clone().unwrap(), postgres::TlsMode::None).unwrap()
}
}
impl MedalObject<Connection> for Submission {
......@@ -287,6 +294,8 @@ impl MedalObject<Connection> for Contest {
}
impl MedalConnection for Connection {
fn reconnect(config: &config::Config) -> Self { Self::reconnect_concrete(config) }
fn dbtype(&self) -> &'static str { "postgres" }
fn migration_already_applied(&self, name: &str) -> bool {
......@@ -420,16 +429,21 @@ impl MedalConnection for Connection {
SessionUser::minimal(id, session_token.to_owned(), csrf_token)
}
fn get_session_or_new(&self, key: &str) -> SessionUser {
let query = "UPDATE session
SET session_token = $1
WHERE session_token = $2";
self.get_session(&key).ensure_alive().unwrap_or_else(|| {
// TODO: Factor this out in own function
// TODO: Should a new session key be generated every time?
self.execute(query, &[&Option::<String>::None, &key]).unwrap();
self.new_session(&key)
})
fn get_session_or_new(&self, key: &str) -> Result<SessionUser, ()> {
fn disable_old_session_and_create_new(conn: &Connection, key: &str) -> Result<SessionUser, ()> {
let query = "UPDATE session
SET session_token = $1
WHERE session_token = $2";
// TODO: Should a new session key be generated every time?
conn.execute(query, &[&Option::<String>::None, &key]).map_err(|_| ())?;
Ok(conn.new_session(&key))
}
if let Some(session) = self.get_session(&key).ensure_alive() {
Ok(session)
} else {
disable_old_session_and_create_new(self, key)
}
}
fn get_user_by_id(&self, user_id: i32) -> Option<SessionUser> {
......@@ -691,7 +705,7 @@ impl MedalConnection for Connection {
fn signup(&self, session_token: &str, username: &str, email: &str, password_hash: String, salt: &str)
-> SignupResult {
let mut session_user = self.get_session_or_new(&session_token);
let mut session_user = self.get_session_or_new(&session_token).unwrap();
if session_user.is_logged_in() {
return SignupResult::UserLoggedIn;
......@@ -986,6 +1000,7 @@ impl MedalConnection for Connection {
"teacher_firstname",
"teacher_lastname",
"teacher_oauth_foreign_id",
"teacher_oauth_school_id",
"teacher_oauth_provider",
"contest_id",
"start_date"];
......@@ -1046,6 +1061,17 @@ impl MedalConnection for Connection {
for i in 20..20 + taskgroups.len() {
points.push(row.get::<_, Option<i32>>(i));
}
let teacher_oauth_and_school_id = row.get::<_, Option<String>>(15);
let (teacher_oauth_id, teacher_school_id) = if let Some(toasi) = teacher_oauth_and_school_id {
let mut v = toasi.split('/');
let oid: Option<String> = v.next().map(|s| s.to_owned());
let sid: Option<String> = v.next().map(|s| s.to_owned());
(oid, sid)
} else {
(None, None)
};
// Serialized as several tuples because Serde only supports tuples up to a certain length
// (16 according to https://docs.serde.rs/serde/trait.Deserialize.html)
wtr.serialize(((row.get::<_, i32>(0),
......@@ -1064,7 +1090,8 @@ impl MedalConnection for Connection {
row.get::<_, Option<i32>>(13),
row.get::<_, Option<String>>(14),
row.get::<_, Option<String>>(15),
row.get::<_, Option<String>>(16),
teacher_oauth_id,
teacher_school_id,
row.get::<_, Option<String>>(17)),
row.get::<_, Option<i32>>(18),
row.get::<_, Option<self::time::Timespec>>(19)
......@@ -1528,13 +1555,14 @@ impl MedalConnection for Connection {
let query = "SELECT id, firstname, lastname
FROM session
WHERE oauth_foreign_id = $1
OR oauth_foreign_id LIKE $2
LIMIT 30";
Ok(self.query_map_many(query, &[&pms_id], |row| (row.get(0), row.get(1), row.get(2))).unwrap())
Ok(self.query_map_many(query, &[&pms_id, &format!("{}/%", pms_id)], |row| (row.get(0), row.get(1), row.get(2))).unwrap())
} else if let (Some(firstname), Some(lastname)) = (s_firstname, s_lastname) {
let query = "SELECT id, firstname, lastname
FROM session
WHERE firstname LIKE $1
AND lastname LIKE $2
WHERE firstname ILIKE $1
AND lastname ILIKE $2
LIMIT 30";
Ok(self.query_map_many(query, &[&firstname, &lastname], |row| (row.get(0), row.get(1), row.get(2)))
.unwrap())
......
......@@ -16,6 +16,7 @@
extern crate rusqlite;
use config;
use rusqlite::Connection;
use time;
use time::Duration;
......@@ -34,6 +35,8 @@ trait Queryable {
where F: FnMut(&rusqlite::Row) -> T;
fn exists(&self, sql: &str, params: &[&dyn rusqlite::types::ToSql]) -> bool;
fn get_last_id(&self) -> Option<i32>;
fn reconnect_concrete(&config::Config) -> Self;
}
impl Queryable for Connection {
......@@ -62,6 +65,10 @@ impl Queryable for Connection {
}
fn get_last_id(&self) -> Option<i32> { self.query_row("SELECT last_insert_rowid()", &[], |row| row.get(0)).ok() }
fn reconnect_concrete(config: &config::Config) -> Self {
rusqlite::Connection::open(config.database_file.clone().unwrap()).unwrap()
}
}
impl MedalObject<Connection> for Submission {
......
......@@ -29,6 +29,7 @@
extern crate rusqlite;
use config;
use rusqlite::Connection;
use time;
use time::Duration;
......@@ -47,6 +48,8 @@ trait Queryable {
where F: FnMut(&rusqlite::Row) -> T;
fn exists(&self, sql: &str, params: &[&dyn rusqlite::types::ToSql]) -> bool;
fn get_last_id(&self) -> Option<i32>;
fn reconnect_concrete(&config::Config) -> Self;
}
impl Queryable for Connection {
......@@ -75,6 +78,10 @@ impl Queryable for Connection {
}
fn get_last_id(&self) -> Option<i32> { self.query_row("SELECT last_insert_rowid()", &[], |row| row.get(0)).ok() }
fn reconnect_concrete(config: &config::Config) -> Self {
rusqlite::Connection::open(config.database_file.clone().unwrap()).unwrap()
}
}
impl MedalObject<Connection> for Submission {
......@@ -287,6 +294,8 @@ impl MedalObject<Connection> for Contest {
}
impl MedalConnection for Connection {
fn reconnect(config: &config::Config) -> Self { Self::reconnect_concrete(config) }
fn dbtype(&self) -> &'static str { "sqlite_v2" }
fn migration_already_applied(&self, name: &str) -> bool {
......@@ -420,16 +429,21 @@ impl MedalConnection for Connection {
SessionUser::minimal(id, session_token.to_owned(), csrf_token)
}
fn get_session_or_new(&self, key: &str) -> SessionUser {
let query = "UPDATE session
SET session_token = ?1
WHERE session_token = ?2";
self.get_session(&key).ensure_alive().unwrap_or_else(|| {
// TODO: Factor this out in own function
// TODO: Should a new session key be generated every time?
self.execute(query, &[&Option::<String>::None, &key]).unwrap();
self.new_session(&key)
})
fn get_session_or_new(&self, key: &str) -> Result<SessionUser, ()> {
fn disable_old_session_and_create_new(conn: &Connection, key: &str) -> Result<SessionUser, ()> {
let query = "UPDATE session
SET session_token = ?1
WHERE session_token = ?2";
// TODO: Should a new session key be generated every time?
conn.execute(query, &[&Option::<String>::None, &key]).map_err(|_| ())?;
Ok(conn.new_session(&key))
}
if let Some(session) = self.get_session(&key).ensure_alive() {
Ok(session)
} else {
disable_old_session_and_create_new(self, key)
}
}
fn get_user_by_id(&self, user_id: i32) -> Option<SessionUser> {
......@@ -691,7 +705,7 @@ impl MedalConnection for Connection {
fn signup(&self, session_token: &str, username: &str, email: &str, password_hash: String, salt: &str)
-> SignupResult {
let mut session_user = self.get_session_or_new(&session_token);
let mut session_user = self.get_session_or_new(&session_token).unwrap();
if session_user.is_logged_in() {
return SignupResult::UserLoggedIn;
......@@ -986,6 +1000,7 @@ impl MedalConnection for Connection {
"teacher_firstname",
"teacher_lastname",
"teacher_oauth_foreign_id",
"teacher_oauth_school_id",
"teacher_oauth_provider",
"contest_id",
"start_date"];
......@@ -1046,6 +1061,17 @@ impl MedalConnection for Connection {
for i in 20..20 + taskgroups.len() {
points.push(row.get::<_, Option<i32>>(i));
}
let teacher_oauth_and_school_id = row.get::<_, Option<String>>(15);
let (teacher_oauth_id, teacher_school_id) = if let Some(toasi) = teacher_oauth_and_school_id {
let mut v = toasi.split('/');
let oid: Option<String> = v.next().map(|s| s.to_owned());
let sid: Option<String> = v.next().map(|s| s.to_owned());
(oid, sid)
} else {
(None, None)
};
// Serialized as several tuples because Serde only supports tuples up to a certain length
// (16 according to https://docs.serde.rs/serde/trait.Deserialize.html)
wtr.serialize(((row.get::<_, i32>(0),
......@@ -1064,7 +1090,8 @@ impl MedalConnection for Connection {
row.get::<_, Option<i32>>(13),
row.get::<_, Option<String>>(14),
row.get::<_, Option<String>>(15),
row.get::<_, Option<String>>(16),
teacher_oauth_id,
teacher_school_id,
row.get::<_, Option<String>>(17)),
row.get::<_, Option<i32>>(18),
row.get::<_, Option<self::time::Timespec>>(19)
......@@ -1528,8 +1555,9 @@ impl MedalConnection for Connection {
let query = "SELECT id, firstname, lastname
FROM session
WHERE oauth_foreign_id = ?1
OR oauth_foreign_id LIKE ?2
LIMIT 30";
Ok(self.query_map_many(query, &[&pms_id], |row| (row.get(0), row.get(1), row.get(2))).unwrap())
Ok(self.query_map_many(query, &[&pms_id, &format!("{}/%", pms_id)], |row| (row.get(0), row.get(1), row.get(2))).unwrap())
} else if let (Some(firstname), Some(lastname)) = (s_firstname, s_lastname) {
let query = "SELECT id, firstname, lastname
FROM session
......
#!/bin/sh
cat db_conn_warning_header.txt db_conn_sqlite_new.header.rs db_conn.base.rs | sed 's/\$/\?/g' | sed 's/{ "postgres" }/{ "sqlite_v2" }/' | sed 's/batch_execute/execute_batch/' > db_conn_sqlite_new.rs
cat db_conn_warning_header.txt db_conn_sqlite_new.header.rs db_conn.base.rs | sed 's/\$/\?/g' | sed 's/{ "postgres" }/{ "sqlite_v2" }/' | sed 's/batch_execute/execute_batch/' | sed 's/ILIKE/LIKE/' > db_conn_sqlite_new.rs
cat db_conn_warning_header.txt db_conn_postgres.header.rs db_conn.base.rs > db_conn_postgres.rs
......@@ -214,7 +214,6 @@ fn main() {
// Let options override config values
opt.databasefile.map(|x| config.database_file = Some(x));
opt.databaseurl.map(|x| config.database_url = Some(x));
opt.teacherpage.map(|x| config.teacher_page = Some(x));
opt.port.map(|x| config.port = Some(x));
config.no_contest_scan = if opt.nocontestscan { Some(true) } else { config.no_contest_scan };
config.open_browser = if opt.openbrowser { Some(true) } else { config.open_browser };
......
......@@ -15,6 +15,7 @@
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
pub struct OauthProvider {
pub provider_id: String,
pub medal_oauth_type: String,
pub url: String,
pub client_id: String,
pub client_secret: String,
......
This diff is collapsed.
This diff is collapsed.
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M16 12H6c-1.7 0-3 1.3-3 3h13v3l5-4.5L16 9v3z"/>
</svg>
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g id="external">
<path id="box" d="M4 4h6v2H6v12h12v-4h2v6H4z"/>
<path id="arrow" d="M12.42 4H20v7.58l-2.84-2.846L12.892 13 11 11.106l4.264-4.266z"/>
</g>