diff --git a/.deployment/files/known-groups.json b/.deployment/files/known-groups.json index cc90e9e7b..91c22fa98 100644 --- a/.deployment/files/known-groups.json +++ b/.deployment/files/known-groups.json @@ -1,6 +1,6 @@ { - "ROLE_STUDENT": { "label": { "en": "Students", "de": "Studierende" }, "implies": [], "large": true }, - "ROLE_STAFF": { "label": { "en": "Staff", "de": "Angestellte" }, "implies": [], "large": true }, - "ROLE_INSTRUCTOR": { "label": { "en": "Lecturers", "de": "Vortragende" }, "implies": ["ROLE_STAFF"], "large": true }, - "ROLE_TOBIRA_MODERATOR": { "label": { "en": "Moderators", "de": "Moderierende" }, "implies": ["ROLE_STAFF"], "large": false } + "ROLE_STUDENT": { "label": { "default": "Students", "de": "Studierende" }, "implies": [], "large": true }, + "ROLE_STAFF": { "label": { "default": "Staff", "de": "Angestellte" }, "implies": [], "large": true }, + "ROLE_INSTRUCTOR": { "label": { "default": "Lecturers", "de": "Vortragende" }, "implies": ["ROLE_STAFF"], "large": true }, + "ROLE_TOBIRA_MODERATOR": { "label": { "default": "Moderators", "de": "Moderierende" }, "implies": ["ROLE_STAFF"], "large": false } } diff --git a/backend/src/api/common.rs b/backend/src/api/common.rs index 7c55a5b83..570898ba9 100644 --- a/backend/src/api/common.rs +++ b/backend/src/api/common.rs @@ -17,7 +17,6 @@ use crate::{ }, prelude::*, search::Playlist as SearchPlaylist, - db::types::ExtraMetadata, }; @@ -100,46 +99,3 @@ impl Cursor { Ok(Self(s.into())) } } - -// TODO: This uses `graphql_scalar` instead of `derive(GraphQLScalar)` because -// the type `ExtraMetadata` is defined in the `db` module and adding GraphQL -// code there seems wrong. However, I feel like we should move some types -// around anyway since we encountered problems like this before. -#[juniper::graphql_scalar( - name = "ExtraMetadata", - description = "Arbitrary metadata for events/series. Serialized as JSON object.", - with = Self, - parse_token(String), -)] -#[allow(dead_code)] -pub type ApiExtraMetadata = ExtraMetadata; - -impl ExtraMetadata { - fn to_output(&self) -> juniper::Value { - use juniper::Value; - - std::iter::once(("dcterms", &self.dcterms)) - .chain(self.extended.iter().map(|(k, v)| (&**k, v))) - .map(|(k, v)| { - let value = v.iter() - .map(|(k, v)| { - let elements = v.iter() - .map(|s| Value::Scalar(S::from(s.clone()))) - .collect(); - (k, Value::List(elements)) - }) - .collect::>(); - - (k, Value::Object(value)) - }) - .collect::>() - .pipe(Value::Object) - } - - fn from_input(input: &InputValue) -> Result { - // I did not want to waste time implementing this now, given that we - // likely never use it. - let _ = input; - todo!("ExtraMetadata cannot be used as input value yet") - } -} diff --git a/backend/src/api/id.rs b/backend/src/api/id.rs index a19302227..7ac32c810 100644 --- a/backend/src/api/id.rs +++ b/backend/src/api/id.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use static_assertions::const_assert; use std::fmt; -use crate::{db::types::Key, util::{base64_decode, BASE64_DIGITS}}; +use crate::model::Key; /// An opaque, globally-unique identifier for all "nodes" that the GraphQL API @@ -138,30 +138,6 @@ impl Id { } } -impl Key { - pub(crate) fn from_base64(s: &str) -> Option { - if s.len() != 11 { - return None; - } - - decode_base64(s.as_bytes()) - } - - pub(crate) fn to_base64<'a>(&self, out: &'a mut [u8; 11]) -> &'a str { - // Base64 encoding. After this loop, `n` is always 0, because `u64::MAX` - // divided by 64 eleven times is 0. - let mut n = self.0; - for i in (0..out.len()).rev() { - out[i] = BASE64_DIGITS[(n % 64) as usize]; - n /= 64; - } - debug_assert!(n == 0); - - std::str::from_utf8(out) - .expect("bug: base64 did produce non-ASCII character") - } -} - impl std::str::FromStr for Id { type Err = &'static str; @@ -191,31 +167,10 @@ impl fmt::Display for Id { } } - -fn decode_base64(src: &[u8]) -> Option { - let src: [u8; 11] = src.try_into().ok()?; - - // Make sure the string doesn't decode to a number > `u64::MAX`. Luckily, - // checking that is easy. `u64::MAX` encodes to `P__________`, so the next - // higher number would carry through and make the highest digit a `Q`. So we - // just make sure the first digit is between 'A' and 'P'. - if src[0] > b'P' || src[0] < b'A' { - return None; - } - - src.iter() - .rev() - .enumerate() - .map(|(i, &d)| base64_decode(d).map(|n| n as u64 * 64u64.pow(i as u32))) - .sum::>() - .map(Key) -} - - #[cfg(test)] mod tests { use std::str::FromStr; - use super::{Id, Key, BASE64_DIGITS}; + use super::{Id, Key}; #[test] fn simple() { @@ -265,7 +220,7 @@ mod tests { let id = Id { kind: Id::REALM_KIND, key: Key((n as u64) << shift) }; let s = id.to_string(); assert_eq!(s[..2].as_bytes(), Id::REALM_KIND); - assert!(s[2..].bytes().all(|d| BASE64_DIGITS.contains(&d))); + assert!(s[2..].bytes().all(|d| crate::util::BASE64_DIGITS.contains(&d))); } } } diff --git a/backend/src/api/model/acl.rs b/backend/src/api/model/acl.rs index e5c9b200d..56bcd1394 100644 --- a/backend/src/api/model/acl.rs +++ b/backend/src/api/model/acl.rs @@ -2,7 +2,7 @@ use juniper::{GraphQLInputObject, GraphQLObject}; use postgres_types::BorrowToSql; use serde::Serialize; -use crate::{api::{util::TranslatedString, Context, err::ApiResult}, db::util::select}; +use crate::{api::{err::ApiResult, Context}, model::TranslatedString, db::util::select}; @@ -31,9 +31,9 @@ pub(crate) struct AclItem { #[graphql(context = Context)] pub(crate) struct RoleInfo { /// A user-facing label for this role (group or person). If the label does - /// not depend on the language (e.g. a name), `{ "_": "Peter" }` is + /// not depend on the language (e.g. a name), `{ "default": "Peter" }` is /// returned. - pub label: TranslatedString, + pub label: TranslatedString, /// For user roles this is `null`. For groups, it defines a list of other /// group roles that this role implies. I.e. a user with this role always @@ -66,7 +66,7 @@ where known_groups.label, case when users.display_name is null then null - else hstore('_', users.display_name) + else hstore('default', users.display_name) end )", ); diff --git a/backend/src/api/model/block/mod.rs b/backend/src/api/model/block/mod.rs index fdfc59300..b134d73bb 100644 --- a/backend/src/api/model/block/mod.rs +++ b/backend/src/api/model/block/mod.rs @@ -15,7 +15,8 @@ use crate::{ Context, Id, }, - db::{types::Key, util::impl_from_db}, + model::Key, + db::util::impl_from_db, prelude::*, }; diff --git a/backend/src/api/model/block/mutations.rs b/backend/src/api/model/block/mutations.rs index 06e3c4f7b..f038ad699 100644 --- a/backend/src/api/model/block/mutations.rs +++ b/backend/src/api/model/block/mutations.rs @@ -2,7 +2,8 @@ use juniper::{GraphQLInputObject, GraphQLObject}; use crate::{ api::{Context, Id, err::{ApiResult, invalid_input}, model::realm::Realm}, - db::{types::Key, util::select}, + db::util::select, + model::Key, prelude::*, }; use super::{BlockValue, VideoListOrder, VideoListLayout}; diff --git a/backend/src/api/model/event.rs b/backend/src/api/model/event.rs index 541ab7d33..f6e21fd2a 100644 --- a/backend/src/api/model/event.rs +++ b/backend/src/api/model/event.rs @@ -24,9 +24,10 @@ use crate::{ }, }, db::{ - types::{EventCaption, EventSegment, EventState, EventTrack, ExtraMetadata, Key, Credentials}, + types::{EventCaption, EventSegment, EventState, EventTrack, Credentials}, util::{impl_from_db, select}, }, + model::{Key, ExtraMetadata}, prelude::*, }; diff --git a/backend/src/api/model/known_roles.rs b/backend/src/api/model/known_roles.rs index 314c4ae09..c207c3348 100644 --- a/backend/src/api/model/known_roles.rs +++ b/backend/src/api/model/known_roles.rs @@ -2,9 +2,10 @@ use meilisearch_sdk::search::{Selectors, MatchingStrategies}; use serde::Deserialize; use crate::{ - api::{Context, err::ApiResult, util::TranslatedString}, - prelude::*, + api::{err::ApiResult, Context}, + model::TranslatedString, db::util::{impl_from_db, select}, + prelude::*, }; use super::search::{handle_search_result, measure_search_duration, SearchResults, SearchUnavailable}; @@ -16,7 +17,7 @@ use super::search::{handle_search_result, measure_search_duration, SearchResults #[derive(juniper::GraphQLObject)] pub struct KnownGroup { pub(crate) role: String, - pub(crate) label: TranslatedString, + pub(crate) label: TranslatedString, pub(crate) implies: Vec, pub(crate) large: bool, } diff --git a/backend/src/api/model/playlist/mod.rs b/backend/src/api/model/playlist/mod.rs index 010ce4152..55de1b8fe 100644 --- a/backend/src/api/model/playlist/mod.rs +++ b/backend/src/api/model/playlist/mod.rs @@ -5,7 +5,8 @@ use crate::{ api::{ common::NotAllowed, err::ApiResult, Context, Id, Node, NodeValue }, - db::{types::Key, util::{impl_from_db, select}}, + db::util::{impl_from_db, select}, + model::Key, prelude::*, }; diff --git a/backend/src/api/model/realm/mod.rs b/backend/src/api/model/realm/mod.rs index 34b1c1511..8414d5793 100644 --- a/backend/src/api/model/realm/mod.rs +++ b/backend/src/api/model/realm/mod.rs @@ -11,7 +11,8 @@ use crate::{ NodeValue, }, auth::AuthContext, - db::{types::Key, util::{impl_from_db, select}}, + db::util::{impl_from_db, select}, + model::Key, prelude::*, }; use super::block::BlockValue; diff --git a/backend/src/api/model/realm/mutations.rs b/backend/src/api/model/realm/mutations.rs index 5263017d2..5b34144be 100644 --- a/backend/src/api/model/realm/mutations.rs +++ b/backend/src/api/model/realm/mutations.rs @@ -8,7 +8,7 @@ use crate::{ model::block::RemovedBlock, }, auth::AuthContext, - db::types::Key, + model::Key, prelude::*, }; use super::{Realm, RealmOrder}; diff --git a/backend/src/api/model/series.rs b/backend/src/api/model/series.rs index d91a32c26..ab6e5b495 100644 --- a/backend/src/api/model/series.rs +++ b/backend/src/api/model/series.rs @@ -8,7 +8,8 @@ use crate::{ err::{invalid_input, ApiResult}, model::{event::AuthorizedEvent, realm::Realm}, }, - db::{types::{ExtraMetadata, Key, SeriesState as State}, util::impl_from_db}, + db::{types::{SeriesState as State}, util::impl_from_db}, + model::{Key, ExtraMetadata}, prelude::*, }; diff --git a/backend/src/api/util.rs b/backend/src/api/util.rs index e4accd2ab..3e945a1ba 100644 --- a/backend/src/api/util.rs +++ b/backend/src/api/util.rs @@ -1,13 +1,3 @@ -use std::{collections::HashMap, fmt}; - -use bytes::BytesMut; -use fallible_iterator::FallibleIterator; -use juniper::{GraphQLScalar, InputValue, ScalarValue}; -use postgres_types::{FromSql, ToSql}; - -use crate::prelude::*; - - macro_rules! impl_object_with_dummy_field { ($ty:ident) => { @@ -23,67 +13,3 @@ macro_rules! impl_object_with_dummy_field { } pub(crate) use impl_object_with_dummy_field; - - - - -/// A string in different languages. -#[derive(Debug, GraphQLScalar)] -#[graphql( - where(T: AsRef), - parse_token(String), -)] -pub struct TranslatedString(pub(crate) HashMap); - -impl + fmt::Debug> ToSql for TranslatedString { - fn to_sql( - &self, - _: &postgres_types::Type, - out: &mut BytesMut, - ) -> Result> { - let values = self.0.iter().map(|(k, v)| (k.as_ref(), Some(&**v))); - postgres_protocol::types::hstore_to_sql(values, out)?; - Ok(postgres_types::IsNull::No) - } - - fn accepts(ty: &postgres_types::Type) -> bool { - ty.name() == "hstore" - } - - postgres_types::to_sql_checked!(); -} - -impl<'a> FromSql<'a> for TranslatedString { - fn from_sql( - _: &postgres_types::Type, - raw: &'a [u8], - ) -> Result> { - postgres_protocol::types::hstore_from_sql(raw)? - .map(|(k, v)| { - v.map(|v| (k.to_owned(), v.to_owned())) - .ok_or("translated label contained null value in hstore".into()) - }) - .collect() - .map(Self) - } - - fn accepts(ty: &postgres_types::Type) -> bool { - ty.name() == "hstore" - } -} - -impl> TranslatedString { - fn to_output(&self) -> juniper::Value { - self.0.iter() - .map(|(k, v)| (k.as_ref(), juniper::Value::scalar(v.to_owned()))) - .collect::>() - .pipe(juniper::Value::Object) - } - - fn from_input(input: &InputValue) -> Result { - // I did not want to waste time implementing this now, given that we - // likely never use it. - let _ = input; - todo!("TranslatedString cannot be used as input value yet") - } -} diff --git a/backend/src/auth/config.rs b/backend/src/auth/config.rs index de7808077..d6c6b2423 100644 --- a/backend/src/auth/config.rs +++ b/backend/src/auth/config.rs @@ -4,7 +4,7 @@ use hyper::{http::HeaderName, Uri}; use secrecy::SecretString; use serde::{Deserialize, Deserializer, de::Error}; -use crate::{config::{parse_normal_http_uri, TranslatedString}, prelude::*}; +use crate::{config::parse_normal_http_uri, model::TranslatedString, prelude::*}; use super::JwtConfig; diff --git a/backend/src/cmd/known_groups.rs b/backend/src/cmd/known_groups.rs index c4e350528..6b4bd03ad 100644 --- a/backend/src/cmd/known_groups.rs +++ b/backend/src/cmd/known_groups.rs @@ -5,10 +5,11 @@ use postgres_types::ToSql; use serde_json::json; use crate::{ - prelude::*, - db, - api::{util::TranslatedString, model::known_roles::KnownGroup}, + api::model::known_roles::KnownGroup, config::Config, + model::TranslatedString, + db, + prelude::*, }; use super::prompt_for_yes; @@ -25,7 +26,7 @@ pub(crate) enum Args { /// /// { /// "ROLE_LECTURER": { - /// "label": { "en": "Lecturer", "de": "Vortragende" }, + /// "label": { "default": "Lecturer", "de": "Vortragende" }, /// "implies": ["ROLE_STAFF"], /// "large": true /// } @@ -112,10 +113,7 @@ async fn upsert(file: &str, config: &Config, tx: Transaction<'_>) -> Result<()> .context("failed to deserialize")?; // Validate - for (role, info) in &groups { - if info.label.is_empty() { - bail!("No label given for {}", role.0); - } + for role in groups.keys() { if config.auth.is_user_role(&role.0) { bail!("Role '{}' is a user role according to 'auth.user_role_prefixes'. \ This should be added as user, not as group.", role.0); @@ -131,7 +129,7 @@ async fn upsert(file: &str, config: &Config, tx: Transaction<'_>) -> Result<()> label = excluded.label, \ implies = excluded.implies, \ large = excluded.large"; - tx.execute(sql, &[&role, &TranslatedString(info.label), &info.implies, &info.large]).await?; + tx.execute(sql, &[&role, &info.label, &info.implies, &info.large]).await?; } tx.commit().await?; @@ -185,7 +183,7 @@ async fn clear(tx: Transaction<'_>) -> Result<()> { #[derive(serde::Deserialize)] struct GroupData { - label: HashMap, + label: TranslatedString, #[serde(default)] implies: Vec, @@ -193,29 +191,6 @@ struct GroupData { large: bool, } -#[derive(Debug, serde::Deserialize, PartialEq, Eq, Hash)] -#[serde(try_from = "&str")] -struct LangCode([u8; 2]); - -impl<'a> TryFrom<&'a str> for LangCode { - type Error = &'static str; - - fn try_from(v: &'a str) -> std::result::Result { - if !(v.len() == 2 && v.chars().all(|c| c.is_ascii_alphabetic())) { - return Err("invalid language code: two ASCII letters expected"); - } - - let bytes = v.as_bytes(); - Ok(Self([bytes[0], bytes[1]])) - } -} - -impl AsRef for LangCode { - fn as_ref(&self) -> &str { - std::str::from_utf8(&self.0).unwrap() - } -} - #[derive(Debug, serde::Deserialize, PartialEq, Eq, Hash, ToSql)] #[serde(try_from = "String")] #[postgres(transparent)] diff --git a/backend/src/config/general.rs b/backend/src/config/general.rs index 5c5a0b4a1..da72df5c6 100644 --- a/backend/src/config/general.rs +++ b/backend/src/config/general.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; -use super::{HttpHost, TranslatedString}; +use crate::model::TranslatedString; +use super::HttpHost; #[derive(Debug, confique::Config)] @@ -29,9 +30,9 @@ pub(crate) struct GeneralConfig { /// Example: /// /// ``` - /// initial_consent.title.en = "Terms & Conditions" - /// initial_consent.button.en = "Agree" - /// initial_consent.text.en = """ + /// initial_consent.title.default = "Terms & Conditions" + /// initial_consent.button.default = "Agree" + /// initial_consent.text.default = """ /// To use Tobira, you need to agree to our terms and conditions: /// - [Terms](https://www.our-terms.de) /// - [Conditions](https://www.our-conditions.de) @@ -54,8 +55,8 @@ pub(crate) struct GeneralConfig { /// /// ``` /// footer_links = [ - /// { label = { en = "Example 1" }, link = "https://example.com" }, - /// { label = { en = "Example 2" }, link = { en = "https://example.com/en" } }, + /// { label = { default = "Example 1" }, link = "https://example.com" }, + /// { label = { default = "Example 2" }, link = { default = "https://example.com/en" } }, /// "about", /// ] /// ``` @@ -65,8 +66,8 @@ pub(crate) struct GeneralConfig { /// Additional metadata that is shown below a video. Example: /// /// [general.metadata] - /// dcterms.spatial = { en = "Location", de = "Ort" } - /// "http://my.domain/xml/namespace".courseLink = { en = "Course", de = "Kurs"} + /// dcterms.spatial = { default = "Location", de = "Ort" } + /// "http://my.domain/xml/namespace".courseLink = { default = "Course", de = "Kurs"} /// /// As you can see, this is a mapping of a metadata location (the XML /// namespace and the name) to a translated label. For the XML namespace diff --git a/backend/src/config/mod.rs b/backend/src/config/mod.rs index 086d186e4..bc3c08688 100644 --- a/backend/src/config/mod.rs +++ b/backend/src/config/mod.rs @@ -12,14 +12,12 @@ use crate::prelude::*; mod color; mod general; mod theme; -mod translated_string; mod matomo; mod opencast; mod player; mod upload; pub(crate) use self::{ - translated_string::TranslatedString, theme::{ThemeConfig, LogoDef}, matomo::MatomoConfig, opencast::OpencastConfig, diff --git a/backend/src/config/theme.rs b/backend/src/config/theme.rs index fd62fa01b..2d1dbebb4 100644 --- a/backend/src/config/theme.rs +++ b/backend/src/config/theme.rs @@ -1,7 +1,8 @@ use std::{collections::HashMap, fmt, path::PathBuf}; use serde::{Deserialize, Serialize}; -use super::{color::ColorConfig, translated_string::LangKey}; +use crate::model::LangKey; +use super::{color::ColorConfig}; #[derive(Debug, confique::Config)] diff --git a/backend/src/config/translated_string.rs b/backend/src/config/translated_string.rs deleted file mode 100644 index 6fcc56c5a..000000000 --- a/backend/src/config/translated_string.rs +++ /dev/null @@ -1,49 +0,0 @@ -use std::{collections::HashMap, fmt}; -use serde::{Deserialize, Serialize}; -use anyhow::{anyhow, Error}; - -/// A configurable string specified in different languages. Language 'en' always -/// has to be specified. -#[derive(Serialize, Deserialize, Clone)] -#[serde(try_from = "HashMap")] -pub(crate) struct TranslatedString(HashMap); - -impl TranslatedString { - pub(crate) fn default(&self) -> &str { - &self.0[&LangKey::Default] - } -} - -impl TryFrom> for TranslatedString { - type Error = Error; - - fn try_from(map: HashMap) -> Result { - if !map.contains_key(&LangKey::Default) { - return Err(anyhow!("Translated string must include 'default' entry.")); - } - - Ok(Self(map)) - } -} - -impl fmt::Debug for TranslatedString { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("TranslatedString ")?; - f.debug_map().entries(self.0.iter()).finish() - } -} - -#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Debug)] -#[serde(rename_all = "lowercase")] -pub(crate) enum LangKey { - #[serde(alias = "*")] - Default, - En, - De, -} - -impl fmt::Display for LangKey { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.serialize(f) - } -} diff --git a/backend/src/db/tests/mod.rs b/backend/src/db/tests/mod.rs index 70d2f184a..b576f86a3 100644 --- a/backend/src/db/tests/mod.rs +++ b/backend/src/db/tests/mod.rs @@ -1,4 +1,4 @@ -use crate::{prelude::*, db::types::Key}; +use crate::{prelude::*, model::Key}; use super::DbConfig; use self::util::TestDb; diff --git a/backend/src/db/tests/search_queue.rs b/backend/src/db/tests/search_queue.rs index 805351ff7..16a0627d8 100644 --- a/backend/src/db/tests/search_queue.rs +++ b/backend/src/db/tests/search_queue.rs @@ -1,4 +1,4 @@ -use crate::{prelude::*, db::types::Key, search::IndexItemKind}; +use crate::{prelude::*, model::Key, search::IndexItemKind}; use super::util::TestDb; diff --git a/backend/src/db/tests/util.rs b/backend/src/db/tests/util.rs index eb1f9a616..a4531deb4 100644 --- a/backend/src/db/tests/util.rs +++ b/backend/src/db/tests/util.rs @@ -2,7 +2,7 @@ use std::{ops::Deref, collections::HashSet}; use secrecy::ExposeSecret; use tokio_postgres::{Client, NoTls}; -use crate::{prelude::*, db::types::Key, search::IndexItemKind}; +use crate::{prelude::*, model::Key, search::IndexItemKind}; use super::DbConfig; diff --git a/backend/src/db/types.rs b/backend/src/db/types.rs index 405ca60b3..961c3a70d 100644 --- a/backend/src/db/types.rs +++ b/backend/src/db/types.rs @@ -1,4 +1,4 @@ -use std::{fmt, collections::HashMap}; +use std::collections::HashMap; use bytes::BytesMut; use chrono::{DateTime, Utc}; @@ -6,48 +6,7 @@ use juniper::GraphQLEnum; use postgres_types::{FromSql, ToSql}; use serde::{Deserialize, Serialize}; - -/// Our primary database ID type, which we call "key". In the database, it's a -/// `bigint` (`i64`), but we have a separate Rust type for it for several -/// reasons. Implements `ToSql` and `FromSql` by casting to/from `i64`. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub(crate) struct Key(pub(crate) u64); - -impl ToSql for Key { - fn to_sql( - &self, - ty: &postgres_types::Type, - out: &mut BytesMut, - ) -> Result> { - (self.0 as i64).to_sql(ty, out) - } - - fn accepts(ty: &postgres_types::Type) -> bool { - ::accepts(ty) - } - - postgres_types::to_sql_checked!(); -} - -impl<'a> FromSql<'a> for Key { - fn from_sql( - ty: &postgres_types::Type, - raw: &'a [u8], - ) -> Result> { - i64::from_sql(ty, raw).map(|i| Key(i as u64)) - } - - fn accepts(ty: &postgres_types::Type) -> bool { - ::accepts(ty) - } -} - -impl fmt::Debug for Key { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut buf = [0; 11]; - write!(f, "Key({} :: {})", self.0 as i64, self.to_base64(&mut buf)) - } -} +use crate::model::Key; /// Represents the `event_track` type defined in `5-events.sql`. @@ -153,57 +112,6 @@ pub enum TextAssetType { } -/// Represents extra metadata in the DB. Is a map from "namespace" to a -/// `string -> string array` map. -/// -/// Each namespace key is a URL pointing to an XML namespace definition OR -/// `"dcterms"` for the dc terms (most common namespace). The value for each -/// namespace is a simple string-key map where each value is an array of string -/// values. -#[derive(Debug, Serialize, Deserialize, Default)] -#[cfg_attr(test, derive(PartialEq, Eq))] -pub(crate) struct ExtraMetadata { - /// Metadata of the dcterms - #[serde(default)] - pub(crate) dcterms: MetadataMap, - - /// Extended metadata. - #[serde(flatten)] - pub(crate) extended: HashMap, -} - -type MetadataMap = HashMap>; - -impl ToSql for ExtraMetadata { - fn to_sql( - &self, - ty: &postgres_types::Type, - out: &mut BytesMut, - ) -> Result> { - serde_json::to_value(self) - .expect("failed to convert `ExtraMetadata` to JSON value") - .to_sql(ty, out) - } - - fn accepts(ty: &postgres_types::Type) -> bool { - ::accepts(ty) - } - - postgres_types::to_sql_checked!(); -} - -impl<'a> FromSql<'a> for ExtraMetadata { - fn from_sql( - ty: &postgres_types::Type, - raw: &'a [u8], - ) -> Result> { - serde_json::from_value(<_>::from_sql(ty, raw)?).map_err(Into::into) - } - - fn accepts(ty: &postgres_types::Type) -> bool { - ::accepts(ty) - } -} /// Represents the type for the `custom_action_roles` field from `32-custom-actions.sql`. /// This holds a mapping of actions to lists holding roles that are allowed diff --git a/backend/src/main.rs b/backend/src/main.rs index f2ebc7f48..115efbeac 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -24,6 +24,7 @@ mod db; mod http; mod logger; mod metrics; +mod model; mod prelude; mod search; mod sync; diff --git a/backend/src/model/extra_metadata.rs b/backend/src/model/extra_metadata.rs new file mode 100644 index 000000000..a851218e8 --- /dev/null +++ b/backend/src/model/extra_metadata.rs @@ -0,0 +1,98 @@ +use std::collections::HashMap; + +use bytes::BytesMut; +use postgres_types::{FromSql, ToSql}; +use serde::{Deserialize, Serialize}; +use juniper::{GraphQLScalar, InputValue, ScalarValue}; + +use crate::prelude::*; + + + +/// Represents extra metadata in the DB. Is a map from "namespace" to a +/// `string -> string array` map. +/// +/// Each namespace key is a URL pointing to an XML namespace definition OR +/// `"dcterms"` for the dc terms (most common namespace). The value for each +/// namespace is a simple string-key map where each value is an array of string +/// values. +#[derive(Debug, Serialize, Deserialize, Default, GraphQLScalar)] +#[cfg_attr(test, derive(PartialEq, Eq))] +#[graphql( + description = "Arbitrary metadata for events/series. Serialized as JSON object.", + with = Self, + parse_token(String), +)] +pub(crate) struct ExtraMetadata { + /// Metadata of the dcterms + #[serde(default)] + pub(crate) dcterms: MetadataMap, + + /// Extended metadata. + #[serde(flatten)] + pub(crate) extended: HashMap, +} + +type MetadataMap = HashMap>; + +impl ToSql for ExtraMetadata { + fn to_sql( + &self, + ty: &postgres_types::Type, + out: &mut BytesMut, + ) -> Result> { + serde_json::to_value(self) + .expect("failed to convert `ExtraMetadata` to JSON value") + .to_sql(ty, out) + } + + fn accepts(ty: &postgres_types::Type) -> bool { + ::accepts(ty) + } + + postgres_types::to_sql_checked!(); +} + +impl<'a> FromSql<'a> for ExtraMetadata { + fn from_sql( + ty: &postgres_types::Type, + raw: &'a [u8], + ) -> Result> { + serde_json::from_value(<_>::from_sql(ty, raw)?).map_err(Into::into) + } + + fn accepts(ty: &postgres_types::Type) -> bool { + ::accepts(ty) + } +} + +impl ExtraMetadata { + fn to_output(&self) -> juniper::Value { + use juniper::Value; + + std::iter::once(("dcterms", &self.dcterms)) + .chain(self.extended.iter().map(|(k, v)| (&**k, v))) + .map(|(k, v)| { + let value = v.iter() + .map(|(k, v)| { + let elements = v.iter() + .map(|s| Value::Scalar(S::from(s.clone()))) + .collect(); + (k, Value::List(elements)) + }) + .collect::>(); + + (k, Value::Object(value)) + }) + .collect::>() + .pipe(Value::Object) + } + + fn from_input(input: &InputValue) -> Result { + // I did not want to waste time implementing this now, given that we + // likely never use it. + let _ = input; + todo!("ExtraMetadata cannot be used as input value yet") + } +} + diff --git a/backend/src/model/key.rs b/backend/src/model/key.rs new file mode 100644 index 000000000..f8f77d80f --- /dev/null +++ b/backend/src/model/key.rs @@ -0,0 +1,94 @@ +use std::fmt; + +use bytes::BytesMut; +use postgres_types::{FromSql, ToSql}; +use serde::{Deserialize, Serialize}; + +use crate::util::{BASE64_DIGITS, base64_decode}; + + + +/// Our primary ID type, which we call "key". In the database, it's a +/// `bigint` (`i64`), but we have a separate Rust type for it for several +/// reasons. Implements `ToSql` and `FromSql` by casting to/from `i64`. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct Key(pub(crate) u64); + +impl Key { + pub(crate) fn from_base64(s: &str) -> Option { + if s.len() != 11 { + return None; + } + + decode_base64(s.as_bytes()) + } + + pub(crate) fn to_base64<'a>(&self, out: &'a mut [u8; 11]) -> &'a str { + // Base64 encoding. After this loop, `n` is always 0, because `u64::MAX` + // divided by 64 eleven times is 0. + let mut n = self.0; + for i in (0..out.len()).rev() { + out[i] = BASE64_DIGITS[(n % 64) as usize]; + n /= 64; + } + debug_assert!(n == 0); + + std::str::from_utf8(out) + .expect("bug: base64 did produce non-ASCII character") + } +} + +fn decode_base64(src: &[u8]) -> Option { + let src: [u8; 11] = src.try_into().ok()?; + + // Make sure the string doesn't decode to a number > `u64::MAX`. Luckily, + // checking that is easy. `u64::MAX` encodes to `P__________`, so the next + // higher number would carry through and make the highest digit a `Q`. So we + // just make sure the first digit is between 'A' and 'P'. + if src[0] > b'P' || src[0] < b'A' { + return None; + } + + src.iter() + .rev() + .enumerate() + .map(|(i, &d)| base64_decode(d).map(|n| n as u64 * 64u64.pow(i as u32))) + .sum::>() + .map(Key) +} + +impl ToSql for Key { + fn to_sql( + &self, + ty: &postgres_types::Type, + out: &mut BytesMut, + ) -> Result> { + (self.0 as i64).to_sql(ty, out) + } + + fn accepts(ty: &postgres_types::Type) -> bool { + ::accepts(ty) + } + + postgres_types::to_sql_checked!(); +} + +impl<'a> FromSql<'a> for Key { + fn from_sql( + ty: &postgres_types::Type, + raw: &'a [u8], + ) -> Result> { + i64::from_sql(ty, raw).map(|i| Key(i as u64)) + } + + fn accepts(ty: &postgres_types::Type) -> bool { + ::accepts(ty) + } +} + +impl fmt::Debug for Key { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut buf = [0; 11]; + write!(f, "Key({} :: {})", self.0 as i64, self.to_base64(&mut buf)) + } +} diff --git a/backend/src/model/mod.rs b/backend/src/model/mod.rs new file mode 100644 index 000000000..eaa0ce23f --- /dev/null +++ b/backend/src/model/mod.rs @@ -0,0 +1,18 @@ +//! Items that define the domain data model and logic. +//! +//! There are many types that represent "user-visible data", i.e. data that +//! directly models the application domain and not technical helpers (like a DB +//! pool). These are big high level types like `Event`, but also things like +//! `EventTrack` and `TranslatedString`. These commonly don't neatly fit into +//! either of `db`, `api` or any other submodule as they are used in multiple +//! situations (loading from DB, exposing via API, ...). + +mod extra_metadata; +mod key; +mod translated_string; + +pub(crate) use self::{ + extra_metadata::ExtraMetadata, + key::Key, + translated_string::{LangKey, TranslatedString}, +}; diff --git a/backend/src/model/translated_string.rs b/backend/src/model/translated_string.rs new file mode 100644 index 000000000..2bfc63daf --- /dev/null +++ b/backend/src/model/translated_string.rs @@ -0,0 +1,128 @@ +use std::{collections::HashMap, fmt, str::FromStr}; +use bytes::BytesMut; +use fallible_iterator::FallibleIterator; +use juniper::{GraphQLScalar, InputValue, ScalarValue}; +use postgres_types::{FromSql, ToSql}; +use serde::{Deserialize, Serialize}; +use anyhow::{anyhow, Error}; + +use crate::prelude::*; + + +/// A string specified in different languages. Entry 'default' is required. +#[derive(Serialize, Deserialize, Clone, GraphQLScalar)] +#[serde(try_from = "HashMap")] +#[graphql(parse_token(String))] +pub(crate) struct TranslatedString(pub(crate) HashMap); + +impl TranslatedString { + pub(crate) fn default(&self) -> &str { + &self.0[&LangKey::Default] + } + + fn to_output(&self) -> juniper::Value { + self.0.iter() + .map(|(k, v)| (k.as_ref(), juniper::Value::scalar(v.to_owned()))) + .collect::>() + .pipe(juniper::Value::Object) + } + + fn from_input(input: &InputValue) -> Result { + // I did not want to waste time implementing this now, given that we + // likely never use it. + let _ = input; + todo!("TranslatedString cannot be used as input value yet") + } +} + +impl TryFrom> for TranslatedString { + type Error = Error; + + fn try_from(map: HashMap) -> Result { + if !map.contains_key(&LangKey::Default) { + return Err(anyhow!("Translated string must include 'default' entry.")); + } + + Ok(Self(map)) + } +} + +impl fmt::Debug for TranslatedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("TranslatedString ")?; + f.debug_map().entries(self.0.iter()).finish() + } +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub(crate) enum LangKey { + #[serde(alias = "*")] + Default, + En, + De, +} + +impl fmt::Display for LangKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.serialize(f) + } +} + +impl AsRef for LangKey { + fn as_ref(&self) -> &str { + match self { + LangKey::Default => "default", + LangKey::En => "en", + LangKey::De => "de", + } + } +} + +impl FromStr for LangKey { + type Err = serde::de::value::Error; + + fn from_str(s: &str) -> std::result::Result { + Self::deserialize(serde::de::value::BorrowedStrDeserializer::new(s)) + } +} + +impl ToSql for TranslatedString { + fn to_sql( + &self, + _: &postgres_types::Type, + out: &mut BytesMut, + ) -> Result> { + let values = self.0.iter().map(|(k, v)| (k.as_ref(), Some(v.as_str()))); + postgres_protocol::types::hstore_to_sql(values, out)?; + Ok(postgres_types::IsNull::No) + } + + fn accepts(ty: &postgres_types::Type) -> bool { + ty.name() == "hstore" + } + + postgres_types::to_sql_checked!(); +} + + + +impl<'a> FromSql<'a> for TranslatedString { + fn from_sql( + _: &postgres_types::Type, + raw: &'a [u8], + ) -> Result> { + postgres_protocol::types::hstore_from_sql(raw)? + .map(|(k, v)| { + let v = v.ok_or("translated label contained null value in hstore")?; + let k = k.parse()?; + Ok((k, v.to_owned())) + }) + .collect() + .map(Self) + } + + fn accepts(ty: &postgres_types::Type) -> bool { + ty.name() == "hstore" + } +} diff --git a/backend/src/rss.rs b/backend/src/rss.rs index 2ce02806f..0c8f51397 100644 --- a/backend/src/rss.rs +++ b/backend/src/rss.rs @@ -6,8 +6,9 @@ use futures::TryStreamExt; use ogrim::xml; use crate::{ - db::{types::{EventTrack, Key}, self, util::{impl_from_db, FromDb, dbargs}}, + db::{types::EventTrack, self, util::{impl_from_db, FromDb, dbargs}}, http::{Context, response::{bad_request, self, not_found}, Response}, + model::Key, config::HttpHost, prelude::*, }; diff --git a/backend/src/search/event.rs b/backend/src/search/event.rs index 8fa8e6400..13859ed7b 100644 --- a/backend/src/search/event.rs +++ b/backend/src/search/event.rs @@ -9,8 +9,11 @@ use tokio_postgres::GenericClient; use crate::{ api::model::search::{ByteSpan, TextMatch}, - db::{types::{Key, TextAssetType, TimespanText}, - util::{collect_rows_mapped, impl_from_db}}, + db::{ + types::{TextAssetType, TimespanText}, + util::{collect_rows_mapped, impl_from_db} + }, + model::Key, prelude::*, util::{base64_decode, BASE64_DIGITS}, }; diff --git a/backend/src/search/mod.rs b/backend/src/search/mod.rs index c42f33d43..ad75f15d2 100644 --- a/backend/src/search/mod.rs +++ b/backend/src/search/mod.rs @@ -12,7 +12,7 @@ use secrecy::{SecretString, ExposeSecret}; use serde::{Deserialize, Serialize}; use crate::{ - db::types::Key, + model::Key, prelude::*, config::HttpHost, }; diff --git a/backend/src/search/playlist.rs b/backend/src/search/playlist.rs index 62b276f79..30ff9d344 100644 --- a/backend/src/search/playlist.rs +++ b/backend/src/search/playlist.rs @@ -5,7 +5,8 @@ use tokio_postgres::GenericClient; use crate::{ prelude::*, - db::{types::Key, util::{collect_rows_mapped, impl_from_db}}, + model::Key, + db::util::{collect_rows_mapped, impl_from_db}, }; use super::{realm::Realm, util::{self, FieldAbilities}, IndexItem, IndexItemKind, SearchId}; diff --git a/backend/src/search/realm.rs b/backend/src/search/realm.rs index d238cfd22..6efc82374 100644 --- a/backend/src/search/realm.rs +++ b/backend/src/search/realm.rs @@ -3,7 +3,7 @@ use postgres_types::FromSql; use serde::{Serialize, Deserialize}; use tokio_postgres::GenericClient; -use crate::{prelude::*, db::{types::Key, util::{collect_rows_mapped, impl_from_db}}}; +use crate::{prelude::*, model::Key, db::util::{collect_rows_mapped, impl_from_db}}; use super::{util::{self, FieldAbilities}, IndexItem, IndexItemKind, SearchId}; diff --git a/backend/src/search/series.rs b/backend/src/search/series.rs index f2acdce0c..b26bc15d5 100644 --- a/backend/src/search/series.rs +++ b/backend/src/search/series.rs @@ -5,7 +5,8 @@ use tokio_postgres::GenericClient; use crate::{ prelude::*, - db::{types::{Key, SearchThumbnailInfo}, util::{collect_rows_mapped, impl_from_db}}, + model::Key, + db::{types::SearchThumbnailInfo, util::{collect_rows_mapped, impl_from_db}}, }; use super::{realm::Realm, util::{self, FieldAbilities}, IndexItem, IndexItemKind, SearchId}; diff --git a/backend/src/search/update.rs b/backend/src/search/update.rs index 035d82466..6a18cd4a2 100644 --- a/backend/src/search/update.rs +++ b/backend/src/search/update.rs @@ -5,7 +5,8 @@ use std::{ }; use crate::{ - db::{DbConnection, types::Key, util::select}, + db::{DbConnection, util::select}, + model::Key, prelude::*, util::Never, }; diff --git a/backend/src/search/user.rs b/backend/src/search/user.rs index a34f87ff7..5a974a5f9 100644 --- a/backend/src/search/user.rs +++ b/backend/src/search/user.rs @@ -4,7 +4,8 @@ use tokio_postgres::GenericClient; use crate::{ prelude::*, - db::{types::Key, util::{collect_rows_mapped, impl_from_db}}, + db::util::{collect_rows_mapped, impl_from_db}, + model::Key, }; use super::{util::{self, FieldAbilities}, IndexItem, IndexItemKind, SearchId}; diff --git a/backend/src/sync/harvest/response.rs b/backend/src/sync/harvest/response.rs index 1de2b4726..90fe709c3 100644 --- a/backend/src/sync/harvest/response.rs +++ b/backend/src/sync/harvest/response.rs @@ -1,7 +1,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::db::types::{CustomActions, EventCaption, EventTrack, EventSegment, ExtraMetadata}; +use crate::{ + db::types::{CustomActions, EventCaption, EventTrack, EventSegment}, + model::ExtraMetadata, +}; /// What the harvesting API returns. diff --git a/backend/src/sync/text/mod.rs b/backend/src/sync/text/mod.rs index 8c3fd2842..9a54746a9 100644 --- a/backend/src/sync/text/mod.rs +++ b/backend/src/sync/text/mod.rs @@ -10,10 +10,11 @@ use crate::{ config::Config, db::{ self, - types::{EventCaption, EventTextsQueueRecord, Key, TextAssetType, TimespanText}, + types::{EventCaption, EventTextsQueueRecord, TextAssetType, TimespanText}, util::{collect_rows_mapped, select}, DbConnection, }, + model::Key, dbargs, prelude::*, }; diff --git a/docs/docs/setup/config.toml b/docs/docs/setup/config.toml index be2c4e8e0..c3e0458ea 100644 --- a/docs/docs/setup/config.toml +++ b/docs/docs/setup/config.toml @@ -42,9 +42,9 @@ # Example: # # ``` -# initial_consent.title.en = "Terms & Conditions" -# initial_consent.button.en = "Agree" -# initial_consent.text.en = """ +# initial_consent.title.default = "Terms & Conditions" +# initial_consent.button.default = "Agree" +# initial_consent.text.default = """ # To use Tobira, you need to agree to our terms and conditions: # - [Terms](https://www.our-terms.de) # - [Conditions](https://www.our-conditions.de) @@ -68,8 +68,8 @@ # # ``` # footer_links = [ -# { label = { en = "Example 1" }, link = "https://example.com" }, -# { label = { en = "Example 2" }, link = { en = "https://example.com/en" } }, +# { label = { default = "Example 1" }, link = "https://example.com" }, +# { label = { default = "Example 2" }, link = { default = "https://example.com/en" } }, # "about", # ] # ``` @@ -80,8 +80,8 @@ # Additional metadata that is shown below a video. Example: # # [general.metadata] -# dcterms.spatial = { en = "Location", de = "Ort" } -# "http://my.domain/xml/namespace".courseLink = { en = "Course", de = "Kurs"} +# dcterms.spatial = { default = "Location", de = "Ort" } +# "http://my.domain/xml/namespace".courseLink = { default = "Course", de = "Kurs"} # # As you can see, this is a mapping of a metadata location (the XML # namespace and the name) to a translated label. For the XML namespace diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index c8e79364f..792bacee8 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -210,7 +210,7 @@ scalar DateTime "Arbitrary metadata for events/series. Serialized as JSON object." scalar ExtraMetadata -"A string in different languages." +"A string specified in different languages. Entry 'default' is required." scalar TranslatedString "A role being granted permission to perform certain actions." @@ -698,7 +698,7 @@ type RemovedRealm { type RoleInfo { """ A user-facing label for this role (group or person). If the label does - not depend on the language (e.g. a name), `{ "_": "Peter" }` is + not depend on the language (e.g. a name), `{ "default": "Peter" }` is returned. """ label: TranslatedString! diff --git a/frontend/src/ui/Access.tsx b/frontend/src/ui/Access.tsx index 1fe6c1be0..03e8e37c8 100644 --- a/frontend/src/ui/Access.tsx +++ b/frontend/src/ui/Access.tsx @@ -891,7 +891,7 @@ const getLabel = (role: string, label: TranslatedLabel | undefined, i18n: i18n) return i18n.t("acl.admin-user"); } if (label) { - return label[i18n.language] ?? label.en ?? label._; + return label[i18n.language] ?? label.default; } return role; };