1 // Copyright 2020-2021 Ian Jackson and contributors to Otter
2 // SPDX-License-Identifier: AGPL-3.0-or-later
3 // There is NO WARRANTY.
9 use otter_support::crates::*;
10 use otter_base::crates::*;
13 use std::collections::hash_map::HashMap;
14 use std::collections::hash_set::HashSet;
17 use std::convert::TryFrom;
18 use std::time::Duration;
20 use const_default::ConstDefault;
22 use fehler::{throw,throws};
23 use index_vec::{define_index_type, IndexVec};
24 use num_derive::{FromPrimitive, ToPrimitive};
25 use serde::{Deserialize, Serialize};
26 use strum::{Display, EnumIter, EnumString};
29 use otter_base::geometry::{Coord,Pos,CoordinateOverflow};
30 use otter_base::hformat_as_display;
32 use crate::accounts::AccountName;
33 use crate::error::UnsupportedColourSpec;
34 use crate::gamestate::PieceSpec;
35 use crate::materials_format;
36 use crate::prelude::default;
37 use crate::utils::SVGSizeError;
39 pub use imp::PlayerAccessSpec;
43 //---------- common types ----------
45 #[derive(Clone,Eq,PartialEq,Ord,PartialOrd,Hash,Serialize,Deserialize)]
47 pub struct RawToken(pub String);
49 pub type RawFaceId = u8;
51 #[derive(Default,ConstDefault)]
52 pub struct FaceId = RawFaceId;
53 IMPL_RAW_CONVERSIONS = true;
56 #[derive(Serialize,Deserialize)]
57 #[derive(Debug,Default,Clone)]
60 pub struct ColourSpec(pub String);
62 #[derive(Debug,Default,Clone,Eq,PartialEq,Hash,Ord,PartialOrd)]
63 #[derive(Serialize,Deserialize)]
66 pub struct UrlSpec(pub String);
68 #[derive(Error,Clone,Serialize,Deserialize,Debug)]
70 #[error("improper size specification")] ImproperSizeSpec,
71 #[error("{0}")] UnsupportedColourSpec(#[from] UnsupportedColourSpec),
72 #[error("specified face not found")] FaceNotFound,
73 #[error("internal error: {0}")] InternalError(String),
74 #[error("specified position is off table")] PosOffTable,
75 #[error("library not found")] LibraryNotFound,
76 #[error("item not found in library: {0:?}")] LibraryItemNotFound(ItemSpec),
77 #[error("acl contains invalid account glob")] AclInvalidAccountGlob,
78 #[error("acl entry allow/deny overlap")] AclEntryOverlappingAllowDeny,
79 #[error("inconsistent piece count")] InconsistentPieceCount,
80 #[error("bad url syntax")] BadUrlSyntax,
81 #[error("url too long")] UrlTooLong,
82 #[error("compass angle invalid")] CompassAngleInvalid,
83 #[error("piece has zero faces")] ZeroFaces,
84 #[error("inconsistent face/edge colours")] InconsistentFacesEdgecoloursCount,
85 #[error("specified with of edges, but no edges")] SpecifiedWidthOfNoEdges,
86 #[error("shape not supported")] UnsupportedShape,
87 #[error("negative timeout")] NegativeTimeout,
88 #[error("complex piece where inert needed")] ComplexPieceWhereInertRequired,
89 #[error("piece alias not found")] AliasNotFound,
90 #[error("piece alias target is multi spec")] AliasTargetMultiSpec,
91 #[error("invalid range ({0} to {1})")] InvalidRange(usize,usize),
92 #[error("piece image/alias depth exceeded")] ImageOrAliasLoop,
93 #[error("invalid size scale")] InvalidSizeScale,
94 #[error("multiple faces required")] MultipleFacesRequired,
95 #[error("far too many faces ({0})")] FarTooManyFaces(usize),
96 #[error("currency quantity not multiple of minimum unit")]
97 CurrencyQtyNotMultipleOfUnit,
98 #[error("coordinate overflow")]
99 CoordinateOverflow(#[from] CoordinateOverflow),
100 #[error("SVG handling error: {item_name} for {item_for_lib} {item_for_item} {error}")] SVGError {
102 item_for_lib: String,
103 item_for_item: String,
106 #[error("image for supposedly-occultable piece \
107 is not itself occultable but has multiple faces")]
108 UnoccultableButRichImageForOccultation,
109 #[error("timeout too large ({got:?} > {max:?})")]
114 #[error("wrong number of faces {got_why}={got} != {exp_why}={exp}")]
118 got_why: Cow<'static, str>,
119 exp_why: Cow<'static, str>,
123 //---------- Bundle "otter.toml" file ----------
125 #[derive(Debug,Clone,Serialize,Deserialize)]
126 pub struct BundleMeta {
128 #[serde(default, rename="format")]
129 pub mformat: materials_format::Version,
132 //---------- Table TOML file ----------
134 #[derive(Debug,Serialize,Deserialize)]
135 pub struct TableSpec {
136 #[serde(default)] pub players: Vec<TablePlayerSpec>,
137 pub player_perms: Option<HashSet<TablePermission>>,
138 #[serde(default)] pub acl: Acl<TablePermission>,
139 #[serde(default)] pub links: HashMap<LinkKind, UrlSpec>,
142 #[derive(Debug,Serialize,Deserialize)]
143 #[serde(rename_all="snake_case")]
144 pub enum TablePlayerSpec {
145 Account(AccountName),
152 pub type RawAcl<Perm> = Vec<AclEntry<Perm>>;
154 #[derive(Debug,Clone)]
155 #[derive(Deserialize)]
156 #[serde(try_from="RawAcl<Perm>")]
157 pub struct Acl<Perm: Eq + Hash> { pub ents: RawAcl<Perm> }
159 #[derive(Debug,Clone,Serialize,Deserialize)]
160 pub struct AclEntry<Perm: Eq + Hash> {
161 pub account_glob: String, // checked
162 pub allow: HashSet<Perm>,
163 pub deny: HashSet<Perm>,
166 #[derive(Debug,Clone,Copy,Serialize,Deserialize)]
167 #[derive(Hash,Eq,PartialEq,Ord,PartialOrd)]
168 #[derive(FromPrimitive,ToPrimitive,EnumIter)]
169 pub enum TablePermission {
179 RedeliverOthersAccess,
184 #[derive(Copy,Clone,Debug,Eq,PartialEq,Ord,PartialOrd,Hash)]
185 #[derive(Enum,EnumString,Display)]
186 #[derive(Serialize,Deserialize)]
191 hformat_as_display!{LinkKind}
193 //---------- player accesses, should perhaps be in commands.rs ----------
195 #[derive(Debug,Serialize,Deserialize)]
196 pub struct PlayerAccessUnset;
198 #[derive(Debug,Serialize,Deserialize)]
199 pub struct FixedToken { pub token: RawToken }
201 #[derive(Debug,Serialize,Deserialize)]
202 pub struct TokenByEmail {
203 /// RFC822 recipient field syntax (therefore, ASCII)
207 #[derive(Debug,Serialize,Deserialize)]
208 pub struct UrlOnStdout;
210 //---------- Game TOML file ----------
212 #[derive(Debug,Serialize,Deserialize)]
213 pub struct GameSpec {
214 #[serde(default="imp::def_table_size")] pub table_size: Pos,
215 #[serde(default)] pub pieces: Vec<PiecesSpec>,
216 #[serde(default="imp::def_table_colour")] pub table_colour: ColourSpec,
217 #[serde(default)] pub pcaliases: HashMap<String, Box<dyn PieceSpec>>,
218 #[serde(default, rename="format")] pub mformat: materials_format::Version,
221 #[derive(Debug, Serialize, Deserialize)]
222 pub struct PiecesSpec {
223 pub pos: Option<Pos>,
224 pub posd: Option<Pos>,
225 pub count: Option<u32>,
226 pub face: Option<FaceId>,
227 pub pinned: Option<bool>,
228 #[serde(default)] pub angle: Option<PieceAngleSpec>,
230 pub info: Box<dyn PieceSpec>,
233 #[derive(Debug,Clone,Serialize,Deserialize)]
235 pub enum PieceAngleSpec {
240 #[derive(Debug,Copy,Clone,Serialize,Deserialize)]
241 pub enum PieceAngle {
242 Compass(CompassAngle),
245 pub fn is_default(&self) -> bool { match self {
246 PieceAngle::Compass(a) => *a == CompassAngle::default(),
247 #[allow(unreachable_patterns)] _ => false,
251 #[derive(Debug,Default,Clone,Serialize,Deserialize)]
252 pub struct TextOptionsSpec {
253 pub colour: Option<ColourSpec>,
254 pub size: Option<f64>,
257 #[derive(Debug,Copy,Clone,Eq,PartialEq)]
258 #[derive(Default,Serialize,Deserialize)]
259 #[serde(try_from="u8")]
261 /// 0 = unrotated, +ve is anticlockwise, units of 45deg
262 pub struct CompassAngle(u8);
264 //---------- Piece specs ----------
265 // the implementations are in shapelib.rs and pieces.rs
267 #[derive(Debug,Clone,Serialize,Deserialize)]
268 pub struct ItemSpec {
273 pub mod piece_specs {
276 pub type FaceColourSpecs = IndexVec<FaceId,ColourSpec>;
278 #[derive(Debug,Serialize,Deserialize)]
279 pub struct SimpleCommon {
280 pub itemname: Option<String>,
281 pub faces: IndexVec<FaceId, ColourSpec>,
282 #[serde(default)] pub edges: IndexVec<FaceId, ColourSpec>,
283 pub edge_width: Option<f64>,
286 #[derive(Debug,Serialize,Deserialize)]
290 pub common: SimpleCommon,
293 #[derive(Debug,Serialize,Deserialize)]
295 pub size: Vec<Coord>,
297 pub common: SimpleCommon,
300 #[derive(Debug,Serialize,Deserialize)]
302 #[serde(flatten)] pub c: OwnedCommon,
305 #[derive(Debug,Serialize,Deserialize)]
306 pub struct PlayerLabel {
307 #[serde(flatten)] pub c: OwnedCommon,
310 #[derive(Debug,Serialize,Deserialize)]
311 pub struct OwnedCommon {
312 pub colour: ColourSpec,
313 pub edge: Option<ColourSpec>,
314 pub edge_width: Option<f64>,
316 pub label: Option<PieceLabel>,
319 #[derive(Debug,Clone,Serialize,Deserialize)]
320 pub struct PieceLabel {
321 #[serde(default)] pub place: PieceLabelPlace,
322 pub colour: Option<ColourSpec>,
325 #[derive(Debug,Copy,Clone,Serialize,Deserialize,Eq,PartialEq)]
326 pub enum PieceLabelPlace {
328 BottomLeftOutside, TopLeftOutside,
331 #[derive(Debug,Serialize,Deserialize)]
333 pub faces: IndexVec<FaceId, ColourSpec>,
334 #[serde(default)] pub edges: IndexVec<FaceId, ColourSpec>,
335 pub edge_width: Option<f64>,
337 pub label: Option<PieceLabel>,
338 #[serde(default)] pub stack_pos: [Coord; 2],
342 // ---------- Implementation - angles ----------
345 pub fn is_rotated(&self) -> bool { match self {
346 &PieceAngle::Compass(CompassAngle(a)) => a != 0,
350 impl Default for PieceAngle {
351 fn default() -> Self { PieceAngle::Compass(default()) }
354 impl TryFrom<u8> for CompassAngle {
355 type Error = SpecError;
357 fn try_from(v: u8) -> Self {
359 else { throw!(SpE::CompassAngleInvalid) }
363 impl From<CompassAngle> for u8 {
364 fn from(a: CompassAngle) -> u8 {
369 //---------- Implementation ----------
373 use crate::prelude::*;
375 type AS = AccountScope;
376 type TPS = TablePlayerSpec;
378 pub fn def_table_size() -> Pos {
381 pub fn def_table_colour() -> ColourSpec {
382 ColourSpec(DEFAULT_TABLE_COLOUR.into())
385 impl Default for piece_specs::PieceLabelPlace {
386 fn default() -> Self { Self::BottomLeft }
389 impl<P: Eq + Hash> Default for Acl<P> {
390 fn default() -> Self { Acl { ents: default() } }
393 impl<P: Eq + Hash + Serialize> Serialize for Acl<P> {
394 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error>
395 { self.ents.serialize(s) }
398 impl<P: Eq + Hash> TryFrom<RawAcl<P>> for Acl<P> {
399 type Error = SpecError;
401 fn try_from(ents: RawAcl<P>) -> Self {
403 glob::Pattern::new(&ent.account_glob)
404 .map_err(|_| SpE::AclInvalidAccountGlob)?;
405 if ! ent.deny.is_disjoint(&ent.allow) {
406 throw!(SpE::AclEntryOverlappingAllowDeny);
413 impl loaded_acl::Perm for TablePermission {
414 type Auth = InstanceName;
415 const TEST_EXISTENCE: Self = TablePermission::TestExistence;
416 const NOT_FOUND: MgmtError = ME::GameNotFound;
419 impl TablePlayerSpec {
420 pub fn account_glob(&self, instance_name: &InstanceName) -> String {
421 fn scope_glob(scope: AccountScope) -> String {
422 let mut out = "".to_string();
423 scope.display_name(&[""], |s| Ok::<_,Void>(out += s)).unwrap();
428 TPS::Account(account) => account.to_string(),
429 TPS::AccountGlob(s) => s.clone(),
430 TPS::SameScope => scope_glob(instance_name.account.scope.clone()),
431 TPS::Local(user) => scope_glob(AS::Unix { user: user.clone() }),
433 // abuse that usernames are not encoded
434 scope_glob(AS::Unix { user: "*".into() })
440 type TDE = TokenDeliveryError;
442 pub fn raw_token_debug_as_str(s: &str, f: &mut fmt::Formatter)
444 let len = min(5, s.len() / 2);
445 write!(f, "{:?}...", &s[0..len])
448 impl Debug for RawToken {
449 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
450 raw_token_debug_as_str(&self.0, f)
454 #[typetag::serde(tag="access")]
455 pub trait PlayerAccessSpec: Debug + Sync + Send {
456 fn override_token(&self) -> Option<&RawToken> {
460 fn check_spec_permission(&self, _: Option<AuthorisationSuperuser>) {
467 token: AccessTokenInfo)
468 -> Result<AccessTokenReport, TDE>;
469 fn describe_html(&self) -> Html {
470 let inner = Html::from_txt(&format!("{:?}", self));
471 hformat!("<code>{}</code>", inner)
476 impl PlayerAccessSpec for PlayerAccessUnset {
477 #[throws(TokenDeliveryError)]
483 _token: AccessTokenInfo) -> AccessTokenReport {
484 AccessTokenReport { lines: vec![
485 "Player access not set, game not accessible to this player"
492 impl PlayerAccessSpec for FixedToken {
494 fn check_spec_permission(&self, auth: Option<AuthorisationSuperuser>) {
495 auth.ok_or(ME::SuperuserAuthorisationRequired)?
497 fn override_token(&self) -> Option<&RawToken> {
500 #[throws(TokenDeliveryError)]
506 _token: AccessTokenInfo) -> AccessTokenReport {
507 AccessTokenReport { lines: vec![ "Fixed access token".to_string() ] }
512 impl PlayerAccessSpec for UrlOnStdout {
514 fn deliver<'t>(&self,
519 token: AccessTokenInfo)
520 -> AccessTokenReport {
521 AccessTokenReport { lines: token.report() }
526 impl PlayerAccessSpec for TokenByEmail {
528 fn deliver<'t>(&self,
533 token: AccessTokenInfo)
534 -> AccessTokenReport {
535 let sendmail = &config().sendmail;
536 let mut command = Command::new(sendmail);
538 #[derive(Debug,Serialize)]
539 struct CommonData<'r> {
540 player_email: &'r str,
543 token_lines: Vec<String>,
545 let common = CommonData {
546 player_email: &self.addr,
547 game_name: g.name.to_string(),
548 token_lines: token.report(),
552 if self.addr.find((&['\r','\n']) as &[char]).is_some() {
553 throw!(anyhow!("email address may not contain line endings"));
556 let (account, _) = ag.lookup(ipl.acctid).context("find account")?;
557 let account = &account.account;
558 let message = match &account.scope {
559 AS::Unix { user } => {
560 #[derive(Debug,Serialize)]
564 common: CommonData<'r>,
570 command.args(&["-f", user]);
571 nwtemplates::render("token-unix.tera", &data)
574 #[derive(Debug,Serialize)]
578 common: CommonData<'r>,
581 account: account.to_string(),
584 nwtemplates::render("token-other.tera", &data)
587 .context("render email template")?;
589 let messagefile = (||{
590 let mut messagefile = tempfile::tempfile().context("tempfile")?;
591 messagefile.write_all(message.as_bytes()).context("write")?;
592 messagefile.flush().context("flush")?;
593 messagefile.rewind().context("seek")?;
594 Ok::<_,AE>(messagefile)
595 })().context("write email to temporary file.")?;
598 .args(&["-oee","-odb","-oi","-t","--"])
601 command.pre_exec(|| {
602 // https://github.com/rust-lang/rust/issues/79731
603 match libc::dup2(2,1) {
605 -1 => Err(io::Error::last_os_error()),
606 x => panic!("dup2(2,1) gave {}", x),
612 .with_context(|| format!("run sendmail ({})", sendmail))?;
614 throw!(anyhow!("sendmail ({}) failed: {} ({})", sendmail, st, st));
617 AccessTokenReport { lines: vec![
618 "Token sent by email.".to_string()
623 #[ext(pub, name=ColourSpecExt)]
624 impl Option<ColourSpec> {
625 #[throws(UnsupportedColourSpec)]
626 fn resolve(&self) -> Colour {
628 .map(TryInto::try_into).transpose()?
629 .unwrap_or_else(|| Html::lit("black").into())
633 impl TryFrom<&ColourSpec> for Colour {
634 type Error = UnsupportedColourSpec;
635 #[throws(UnsupportedColourSpec)]
636 fn try_from(spec: &ColourSpec) -> Colour {
638 static ref RE: Regex = Regex::new(concat!(
639 r"^(?:", r"[[:alpha:]]{1,50}",
640 r"|", r"#[[:xdigit:]]{3}{1,2}",
641 r"|", r"(?:rgba?|hsla?)\([-.%\t 0-9]{1,50}\)",
647 throw!(UnsupportedColourSpec);
649 Html::from_html_string(spec.0.clone())
653 impl TextOptionsSpec {
655 /// Default colour is always black
656 pub fn resolve(&self, default_size: f64) -> TextOptions {
657 let TextOptionsSpec { colour, size } = self;
658 let colour = colour.resolve()?;
659 let size = size.unwrap_or(default_size);
660 TextOptions { colour, size }
665 impl Option<PieceAngleSpec> {
667 fn resolve(&self) -> PieceAngle {
668 use PieceAngleSpec as PAS;
670 None => return default(),
671 Some(PAS::Compass(s)) => {
673 "N" , "NE", "E" , "SE", "S" , "SW", "W" , "NW",
674 ].iter().enumerate().find(|(_i,&exp)| s == exp)
675 .ok_or_else(|| SpE::CompassAngleInvalid)?;
678 Some(PAS::Degrees(deg)) => {
679 let deg = deg.rem_euclid(360);
680 if deg % 45 != 0 { throw!(SpE::CompassAngleInvalid) }
684 PieceAngle::Compass(i.try_into()?)
689 const MAX_LEN: usize = 200;
692 impl TryFrom<&UrlSpec> for Url {
693 type Error = SpecError;
695 fn try_from(spec: &UrlSpec) -> Url {
696 if spec.0.len() > UrlSpec::MAX_LEN {
697 throw!(SpE::UrlTooLong);
699 let base = Url::parse(&config().public_url)
700 .or_else(|_| Url::parse(
701 "https://bad-otter-config-public-url.example.net/"
703 let url = Url::options()
704 .base_url(Some(&base))
706 .map_err(|_| SpE::BadUrlSyntax)?;