diff --git a/.gitignore b/.gitignore index c37cbe326..b85a1c848 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ dist* static/tmp/ static/combined/ -client_session_key.aes -cryptoid_key.bf *.hi *.o *.sqlite3 @@ -29,6 +27,8 @@ uniworx.nix src/Handler/Assist.bak src/Handler/Course.SnapCustom.hs *.orig +/instance .stack-work-* .directory tags +test.log \ No newline at end of file diff --git a/.hlint.yaml b/.hlint.yaml new file mode 100644 index 000000000..b9203d95b --- /dev/null +++ b/.hlint.yaml @@ -0,0 +1,14 @@ +# HLint configuration file +# https://github.com/ndmitchell/hlint +########################## + + - ignore: { name: "Parse error" } + - ignore: { name: "Reduce duplication" } + - ignore: { name: "Use ||" } + - ignore: { name: "Use &&" } + - ignore: { name: "Use ++" } + + - arguments: + - -XQuasiQuotes + - -XTemplateHaskell + - -j diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..e5d116a72 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "AllAutocomplete.showCurrentDocument": false +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..8b60430d0 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,48 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "shell", + "command": "./build.sh", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": false + } + }, + { + "label": "start", + "type": "shell", + "command": "./start.sh", + "group": "build", + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "dedicated", + "showReuseMessage": false + }, + "problemMatcher": [] + }, + { + "label": "test", + "type": "shell", + "command": "./test.sh", + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": true, + "panel": "dedicated", + "showReuseMessage": false + } + } + ] +} \ No newline at end of file diff --git a/ChangeLog.md b/ChangeLog.md index 401601e10..3e3d9dfe8 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,3 +1,37 @@ + * Version 30.01.2019 + + Designänderungen + + * Version 16.01.2019 + + Links für Bequemlichkeiten hinzugefügt (z.B. aktuelles Übungsblatt) + + Liste zugewiesener Abgaben lassen sich nun filtern + + Bugfix: Wenn zwischen Anzeige und Empfang eines Tabellen-Formulars Zeilen verschwinden wird nun eine sinnvolle Fehlermeldung angezeigt + + * Version 30.11.2018 + + Bugfix: Übungsblätter im "bestehen nach Punkten"-Modus werden wieder korrekt gespeichert + + * Version 29.11.2018 + + Bugfix: Formulare innerhalb von Tabellen funktionieren nun auch nach Javascript-Seitenwechsel oder Ändern der Sortierung + + * Version 09.11.2018 + + Bugfix: Zahlreiche Knöpfe/Formulare funktionieren wieder bei eingeschaltetem Javascript + + Verschiedene Verbesserungen für Korrektoren + + * Version 19.10.2018 + + Benutzer können sich in der Testphase komplett selbst löschen + + Hilfe Widget + + Benachrichtigungen per eMail für einige Ereignisse + * Version 18.09.2018 Tooltips funktionieren auch ohne JavaScript @@ -9,11 +43,11 @@ Unterstützung von Tabellenzusammenfassungen, z.B. Punktsummen Intelligente Verteilung von Abgaben auf Korrektoren (z.B. bei Krankheit) - + Übungsblätter können Abgabe von Dateien verbieten und angeben ob ZIP-Archive entpackt werden sollen * Version 06.08.2018 - + Einführung einer Option, ob Dateien automatisch heruntergeladen werden sollen * Version 01.08.2018 diff --git a/README.md b/README.md deleted file mode 100644 index be734df7b..000000000 --- a/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# Quick Start Guide - -The following Description applies to Ubuntu or similar. - -## Clone repository - Clone this repository `git clone https://gitlab.cip.ifi.lmu.de/jost/UniWorX.git` and navigate into the new directory `cd UniWorX`. - -## LDAP - install: - `sudo apt-get install slapd ldap-utils` - - -## PostgreSQL - install: - `sudo apt-get install postgresql` - - switch to user *postgres* (got created during installation): - `sudo -i -u postgres` - - add db user *uniworx*: - `createuser --interactive` - - you'll get a prompt: - - ``` - Enter name of role to add:` - [enter 'uniworx'] - Shall the new role be a superuser? (y/n)` - [not exactly sure. Guess not?] - ``` - - create database *uniworx*: - `createdb uniworx` - - to access the database as user *uniworx* you now need to add a new linux-user called *uniworx*: - `sudo adduser uniworx` - - log-in as new user *uniworx*: - `sudo -i -u uniworx` - - you can now use `psql uniworx` to execute SQL-commands and such. - -## stack - Install with: - `curl -sSL https://get.haskellstack.org/ | sh` - - setup stack and install dependencies: - `stack setup` - - During this step or the next you might get an error that says something about missing C libraries for `ldap` and `lber`. You can install these using - `sudo apt-get install libsasl2-dev libldap2-dev` - - If you get an error that says *You need to install postgresql-server-dev-X.Y for building a server-side extension or libpq-dev for building a client-side application.* - Go ahead an install `libpq-dev` with - `sudo apt-get install libpq-dev` - - Build the app: - `stack build` - - This might take a few minutes if not hours... be prepared. - - install yesod: - `stack install yesod-bin --install-ghc` - -## Add Dumy-Data and run the app - After building the app you can prepare the database and add some dummy data: - `./fill-db.hs` - - Run the app: - `./start.sh` - - `Devel application launched: http://localhost:3000` - means you are good to go. - - If you followed the steps above you should now be able to login as user Gregor Kleen using `LDAP:g.kleen@ifi.lmu.de` as dummy login. - -*** - -# Sources and more infos - PostgreSQl: - https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-16-04 - - stack: https://docs.haskellstack.org/en/stable/README/#how-to-install - - ldap: https://wiki.ubuntuusers.de/OpenLDAP_ab_Precise/ - - -*** - -# PostgreSQL - -Starten als Root: - -# systemctl start postgresql -# find / -name postgresql.conf -# cd /var/lib/pgsql/data/ -# su - postgres - - -psql -U uniworx -d uniworx -h 127.0.0.1 -w - ---Zeige Tabellen -\dt - ---Zeige Tabellen Inhalt: -TABLE "user"; --- Die Anführungszeichen können manchmal weggelassen werden, aber --- bei user sind sie notwendig, da es auch Schlüsselwort in sql ist. - ---Lösche Tabelle "course" und alle davon abhängigen: -DROP TABLE "course" CASCADE; - --- UserId 5 zum Lecturer in SchoolId 1 machen (27 ist laufende Nummer) -INSERT INTO "user_lecturer" (id,"user","school") VALUES (27,5,1); - --- Beenden: -\q - --- Hilfe: -\help diff --git a/app/DevelMain.hs b/app/DevelMain.hs index b327943d8..0a7a89562 100644 --- a/app/DevelMain.hs +++ b/app/DevelMain.hs @@ -33,13 +33,15 @@ module DevelMain where import Prelude import Application (getApplicationRepl, shutdownApp) -import Control.Exception (finally) +import Control.Monad.Catch (finally) import Control.Monad ((>=>)) import Control.Concurrent import Data.IORef import Foreign.Store import Network.Wai.Handler.Warp import GHC.Word +import Control.Monad.Trans.Resource +import Control.Monad.IO.Class -- | Start or restart the server. -- newStore is from foreign-store. @@ -71,13 +73,14 @@ update = do -- | Start the server in a separate thread. start :: MVar () -- ^ Written to when the thread is killed. -> IO ThreadId - start done = do - (port, site, app) <- getApplicationRepl - forkIO (finally (runSettings (setPort port defaultSettings) app) - -- Note that this implies concurrency - -- between shutdownApp and the next app that is starting. - -- Normally this should be fine - (putMVar done () >> shutdownApp site)) + start done = runResourceT $ do + (port, site, app) <- getApplicationRepl + resourceForkIO $ do + finally (liftIO $ runSettings (setPort port defaultSettings) app) + -- Note that this implies concurrency + -- between shutdownApp and the next app that is starting. + -- Normally this should be fine + (liftIO $ putMVar done () >> shutdownApp site) -- | kill the server shutdown :: IO () diff --git a/build.sh b/build.sh new file mode 100755 index 000000000..991d2ff3c --- /dev/null +++ b/build.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +exec -- stack build --fast --flag uniworx:library-only --flag uniworx:dev diff --git a/config/keter_testworx.yml b/config/keter_testworx.yml index 102573866..6ec58b7ba 100644 --- a/config/keter_testworx.yml +++ b/config/keter_testworx.yml @@ -27,13 +27,37 @@ stanzas: - LDAPPASS - LDAPBASE - LDAPSCOPE + - LDAPSEARCHTIME + - LDAPSTRIPES - LDAPTIMEOUT + - LDAPLIMIT - DUMMY_LOGIN - DETAILED_LOGGING - LOG_ALL + - LOGLEVEL + - ALLOW_DEPRECATED - PWFILE - CRYPTOID_KEYFILE - IP_FROM_HEADER + - MAILFROM_NAME + - MAILFROM_EMAIL + - MAILOBJECT_DOMAIN + - SMTPHOST + - SMTPPORT + - SMTPSSL + - SMTPUSER + - SMTPPASS + - SMTPTIMEOUT + - SMTPLIMIT + - MAILSUPPORT + - MAILSUPPORT_NAME + - INSTANCE_ID + - MEMCACHEDHOST + - MEMCACHEDPORT + - MEMCACHEDLIMIT + - MEMCACHEDTIMEOUT + - MEMCACHEDROOT + - MEMCACHEDEXPIRATION # Use the following to automatically copy your bundle upon creation via `yesod # keter`. Uses `scp` internally, so you can set it to a remote destination diff --git a/config/keter_uni2work.yml b/config/keter_uni2work.yml index aefd5a30a..f3fa11860 100644 --- a/config/keter_uni2work.yml +++ b/config/keter_uni2work.yml @@ -27,12 +27,37 @@ stanzas: - LDAPPASS - LDAPBASE - LDAPSCOPE + - LDAPSEARCHTIME + - LDAPSTRIPES - LDAPTIMEOUT + - LDAPLIMIT - DETAILED_LOGGING - LOG_ALL + - LOGLEVEL + - ALLOW_DEPRECATED - PWFILE - CRYPTOID_KEYFILE - IP_FROM_HEADER + - MAILFROM_NAME + - MAILFROM_EMAIL + - MAILOBJECT_DOMAIN + - SMTPHOST + - SMTPPORT + - SMTPSSL + - SMTPUSER + - SMTPPASS + - SMTPTIMEOUT + - SMTPLIMIT + - MAILSUPPORT + - MAILSUPPORT_NAME + - INSTANCE_ID + - MEMCACHEDHOST + - MEMCACHEDPORT + - MEMCACHEDLIMIT + - MEMCACHEDTIMEOUT + - MEMCACHEDROOT + - MEMCACHEDEXPIRATION + # Use the following to automatically copy your bundle upon creation via `yesod # keter`. Uses `scp` internally, so you can set it to a remote destination diff --git a/config/settings.yml b/config/settings.yml index 75d5af052..7c561ddfb 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -8,10 +8,33 @@ host: "_env:HOST:*4" # any IPv4 host port: "_env:PORT:3000" ip-from-header: "_env:IP_FROM_HEADER:false" approot: "_env:APPROOT:http://localhost:3000" +mail-from: + name: "_env:MAILFROM_NAME:Uni2work" + email: "_env:MAILFROM_EMAIL:uniworx@localhost" +mail-object-domain: "_env:MAILOBJECT_DOMAIN:localhost" +mail-verp: + separator: "+" + at-replacement: "=" +mail-support: + name: "_env:MAILSUPPORT_NAME:" + email: "_env:MAILSUPPORT:uni2work@ifi.lmu.de" -detailed-logging: "_env:DETAILED_LOGGING:false" -should-log-all: "_env:LOG_ALL:false" -minimum-log-level: "_env:LOGLEVEL:warn" +job-workers: "_env:JOB_WORKERS:10" +job-flush-interval: "_env:JOB_FLUSH:30" +job-cron-interval: "_env:CRON_INTERVAL:60" +job-stale-threshold: 300 +notification-rate-limit: 3600 +notification-collate-delay: 300 +notification-expiration: 259201 +session-timeout: 7200 + +log-settings: + detailed: "_env:DETAILED_LOGGING:false" + all: "_env:LOG_ALL:false" + minimum-level: "_env:LOGLEVEL:warn" + destination: "_env:LOGDEST:stderr" + +# Debugging auth-dummy-login: "_env:DUMMY_LOGIN:false" allow-deprecated: "_env:ALLOW_DEPRECATED:false" @@ -20,10 +43,11 @@ auth-pw-hash: strength: 14 # Optional values with the following production defaults. -# In development, they default to true. +# In development, they default to the opposite. # reload-templates: false # mutable-static: false # skip-combining: false +# encrypt-errors: true database: user: "_env:PGUSER:uniworx" @@ -43,6 +67,33 @@ ldap: baseDN: "_env:LDAPBASE:" scope: "_env:LDAPSCOPE:WholeSubtree" timeout: "_env:LDAPTIMEOUT:5" + search-timeout: "_env:LDAPSEARCHTIME:5" + pool: + stripes: "_env:LDAPSTRIPES:1" + timeout: "_env:LDAPTIMEOUT:20" + limit: "_env:LDAPLIMIT:10" + +smtp: + host: "_env:SMTPHOST:" + port: "_env:SMTPPORT:25" + ssl: "_env:SMTPSSL:starttls" + auth: + type: "login" + user: "_env:SMTPUSER:" + pass: "_env:SMTPPASS:" + pool: + stripes: "_env:SMTPSTRIPES:1" + timeout: "_env:SMTPTIMEOUT:20" + limit: "_env:SMTPLIMIT:10" + +widget-memcached: + host: "_env:MEMCACHEDHOST:" + port: "_env:MEMCACHEDPORT:11211" + auth: [] + limit: "_env:MEMCACHEDLIMIT:10" + timeout: "_env:MEMCACHEDTIMEOUT:20" + base-url: "_env:MEMCACHEDROOT:" + expiration: "_env:MEMCACHEDEXPIRATION:3600" user-defaults: max-favourites: 12 @@ -52,4 +103,4 @@ user-defaults: time-format: "%R" download-files: false -cryptoid-keyfile: "_env:CRYPTOID_KEYFILE:cryptoid_key.bf" +instance-id: "_env:INSTANCE_ID:instance" diff --git a/config/submission-blacklist b/config/submission-blacklist index ad2a62ccf..dbc7d0c21 100644 --- a/config/submission-blacklist +++ b/config/submission-blacklist @@ -10,3 +10,8 @@ $# Ignoriere rekursiv alle Ordner __MACOSX und ihren Inhalt $# Ignoriere rekursiv alle Dateien .DS_Store (Mac OS) **/.DS_Store + +$# Ignoriere VI-Style-Backup-Files +**/*~ +$# Ignoriere Emacs-Style-Backup-Files +**/.#*# \ No newline at end of file diff --git a/config/test-settings.yml b/config/test-settings.yml index c6e5bf360..23f59aed5 100644 --- a/config/test-settings.yml +++ b/config/test-settings.yml @@ -1,11 +1,10 @@ database: - # NOTE: By design, this setting prevents the PGDATABASE environment variable - # from affecting test runs, so that we don't accidentally affect the - # production database during testing. If you're not concerned about that and - # would like to have environment variable overrides, you could instead use - # something like: - # - # database: "_env:PGDATABASE:uniworx_test" - database: uniworx_test + database: "_env:PGDATABASE_TEST:uniworx_test" + +log-settings: + detailed: true + all: true + minimum-level: "debug" + destination: "test.log" auth-dummy-login: true diff --git a/config/wordlist.txt b/config/wordlist.txt new file mode 100644 index 000000000..c8db40508 --- /dev/null +++ b/config/wordlist.txt @@ -0,0 +1,4096 @@ +freeload +refusing +transpire +bloating +education +sandbag +plow +subdivide +pushcart +certainty +drivable +departure +shortage +never +chip +detonate +swiftly +selective +chef +phrase +pulse +polish +grandly +detached +statute +gratuity +finch +palatable +scoundrel +query +daughter +naturist +sappy +scribble +study +mangy +shore +tartly +favorable +elude +oversight +capital +rummage +sponsor +parchment +matrix +afraid +judiciary +perky +obnoxious +thee +craftwork +overstock +catalyze +croak +museum +proofs +clay +tripod +facial +tiara +parlor +chemicals +tattle +muscular +deputy +onstage +ligament +blame +spelling +reshoot +angles +upward +seltzer +powdery +chrome +monday +trapped +underrate +squash +plop +computing +stoic +subsonic +mortician +lair +apron +pouncing +calculus +deviator +krypton +spinal +graves +plant +droop +powdered +pacify +saddled +caretaker +gear +petunia +perch +ruckus +vengeful +rental +dill +pegboard +rigging +upon +genre +passivism +brook +subscribe +outcast +jargon +crummy +morbidity +deck +humorist +scalded +reassign +carving +cruelty +daffodil +hemlock +fraternal +detection +radiator +despite +manlike +release +iron +deem +jurist +jawless +mumbling +flaky +sequence +casket +grinning +sanitary +retention +reflected +tall +thinness +monument +dayroom +puzzling +jackknife +green +marshland +tumbling +glade +amusable +seldom +tattoo +showing +unit +malt +griminess +radiation +java +unfailing +resource +steadily +cranberry +comrade +cornball +sinless +lecturer +blaspheme +cussed +divisible +variably +emphasis +tiptop +underfed +underfeed +sureness +clang +saturday +blasphemy +tutu +flashback +striving +sandworm +tropics +skies +ritzy +comment +swirl +gambling +koala +unstitch +tainted +manhandle +badly +revocable +spotty +overdress +lapping +woven +smite +movable +sphere +matcher +unbolted +exterior +maimed +navy +ritalin +rocker +class +scary +cactus +area +bobbing +oxford +unbend +untracked +swoop +hypertext +vagabond +jumble +uninstall +creole +tag +elevator +sauna +worried +zippy +muppet +scrawny +payee +cozily +arbitrate +plausible +balcony +sporty +predator +cupbearer +rockstar +unhelpful +legwork +favored +grudging +economist +neatness +lurk +unlisted +showy +overreact +contented +flakily +pastor +petty +overeager +giggly +harpist +purveyor +disallow +penny +everybody +kitchen +etching +amaze +pound +dispose +porous +amperage +eardrum +vegan +frigidly +dreary +grunt +dividable +conical +overblown +dingo +shortcake +dander +obliged +ploy +radiated +everyone +gesture +tying +raging +tinderbox +leverage +cornflake +pointy +garter +lapdog +ashen +impurity +rejoice +ensure +verbose +musky +sudden +proud +amazingly +brunette +trio +handiness +panama +faucet +gangrene +snowfall +stimulant +compound +esteemed +claim +grasp +obsolete +energy +guidable +unshaved +chewy +scarcity +pencil +jaybird +trance +request +easter +little +driller +hanky +reversal +buddy +yoyo +kitty +unsaved +heritage +fabulous +quiver +generous +graffiti +bakery +cackle +utility +untainted +verbally +shut +uninsured +unmindful +protector +swear +wieldable +divorcee +resume +primal +entrust +anew +curtain +storewide +sapling +gluten +outscore +earthly +slinky +barber +plastic +operator +pastrami +porthole +semantic +gaming +shrunk +gangway +grimacing +hypnosis +encrypt +kinetic +grope +professor +decay +tinker +handed +magenta +ascertain +freeware +outbreak +acuteness +suspend +stoneware +litter +buckle +climate +violet +truffle +tubeless +voucher +napkin +cheer +emcee +maritime +deafness +prowess +repose +decoy +tinkling +trifle +citation +collar +audition +operative +marshy +buckskin +zit +undergrad +trapezoid +sphinx +uncured +wafer +floral +footpad +threaten +ouch +bottle +humming +pasty +outpour +phobia +faculty +unsorted +slacker +gravy +darkened +dizziness +equivocal +pedometer +glowing +headphone +stillness +armless +attendee +landscape +worsening +sinister +mobile +header +armoire +washboard +army +massager +revolt +work +spearmint +bullpen +garden +tried +coyness +writing +amply +flounder +armadillo +footbath +sandy +blanching +relive +thigh +barista +setback +mayday +cotton +flagman +unmasking +onward +preview +happier +pantyhose +swooned +thorn +pod +douche +graceless +aflutter +purse +hatching +singer +ecosystem +alone +legend +sprout +wrecker +lyricism +surfboard +icing +preorder +doily +aggregate +bacterium +unflawed +security +nucleus +collide +nutrient +till +boondocks +savings +acutely +overlabor +unlikable +trunks +stapling +awning +finishing +thermos +virtuous +smuggling +immodest +turmoil +console +osmosis +external +drown +acid +tumble +hardening +overact +limb +amigo +guacamole +caption +unmolded +nifty +gas +ibuprofen +smitten +slick +scared +abrasion +squeamish +sprung +unworried +prong +aging +zipping +envoy +oppose +recoup +deforest +railcar +sled +throwaway +canopener +uplifted +spew +widow +freestyle +drove +diagnoses +anthem +food +grass +renewable +cone +majority +footgear +collected +balsamic +facsimile +entity +patronize +hypnoses +reggae +railing +chitchat +acting +oversweet +strewn +nineteen +curtsy +gallantly +item +earpiece +tamper +safari +pampered +radish +operation +ramp +reset +dividing +chatter +tasty +deluxe +caliber +audible +marry +pamperer +upfront +rover +canal +cobweb +uncounted +unpledged +gone +stubbly +catacomb +yesterday +glance +jury +tissue +purple +rants +overgrown +freebase +upper +gleeful +chomp +ramrod +anyway +single +gradually +chaste +vendor +staff +unturned +cannon +approve +urging +wager +treadmill +kick +unwieldy +disk +comma +retype +zookeeper +rotunda +backward +galley +liability +oxidize +smugness +dismay +hexagram +lemon +slacks +onto +provoking +strep +strode +quarry +frosted +bleep +quickness +riveting +knapsack +aliens +relatable +handball +acre +commerce +decathlon +reliable +chowder +rebuttal +ending +gush +roaming +sensually +result +upright +resemble +spendable +postwar +winking +font +appraisal +dried +crust +slot +vaguely +augmented +city +uncolored +slighted +negation +macaroni +margarine +shopping +quartet +cymbal +capacity +stilt +exceeding +slinging +disobey +brownnose +clergyman +purely +upchuck +mammary +yam +concert +facecloth +bagpipe +unquote +exquisite +gutter +sincerity +suitcase +patronage +outsmart +subtype +sarcastic +employed +paltry +mutiny +spore +ferry +fried +amicably +aloe +lure +landmine +tux +decimeter +rift +scoreless +eastward +replica +scabbed +unshackle +glaucoma +partly +clamor +tradition +babbling +engaged +bobcat +bobbed +scuba +author +washhouse +companion +emporium +algorithm +bulk +dice +shone +shock +spilt +buddhist +silver +affected +bleak +rejoicing +superbowl +applaud +peroxide +jukebox +jinx +squint +decency +bagful +kinfolk +ergonomic +attire +giddy +nutmeg +visor +stainless +embolism +slideshow +elective +correct +quartered +whenever +coach +fanning +defame +antitoxic +frenzy +fiddle +lego +skirt +seduce +spotter +aloft +jet +situation +helpful +matador +unfixed +stays +bridged +earmark +dork +skydiver +such +attain +unexpired +maverick +triage +doornail +concrete +flashy +entitle +cake +blinking +cofounder +denote +dedicator +smooth +twenty +rockfish +spring +stardust +proactive +expansive +skeptic +emboss +imaging +waggle +scooter +apple +drone +debrief +recount +thespian +plug +lance +caboose +congress +data +shifting +renderer +earlobe +stiffness +voyage +dominoes +tractor +maroon +astound +broiling +clinic +deranged +verify +baguette +enchanted +commodity +laborious +olympics +battering +shrewdly +calculate +espionage +frustrate +gamma +compile +scenic +licking +jaundice +relieving +expiring +available +contusion +habitant +finless +alkaline +upheaval +duress +yelling +headscarf +wooing +riveter +carnivore +oversized +fragment +dispersed +eliminate +giver +chubby +tablet +morse +tightly +dictate +suing +pleading +mullets +probe +reshuffle +retract +supply +haven +unbraided +clarinet +cylinder +consult +tweed +tinwork +pushup +whimsical +salary +putt +sensuous +platypus +distant +luminance +oppressed +reply +ridden +dubbed +kennel +mocha +trapping +refrain +repaint +switch +google +traps +deflected +gander +relative +gumball +willfully +unpaid +random +slingshot +connected +equate +majorette +hesitate +canola +causation +mortify +backyard +finite +scoff +cavalier +hardwired +riches +erasure +arise +primarily +wriggly +unrest +coming +appealing +overbuilt +skimmer +coma +flatware +usage +flatworm +silica +ninth +sandlot +dinginess +doorway +womanlike +outer +tiling +pentagon +strangely +sandbar +corner +dreadlock +moonlight +dean +chop +garbage +surgical +rescuer +decline +slider +abstract +pronto +reexamine +improper +subsoil +antihero +freeing +sacrament +encroach +abnormal +cheese +grandpa +monstrous +kinsman +conjoined +padding +flypaper +vanilla +only +volley +mustard +predefine +chaos +ferocity +undamaged +splashed +affair +careless +zigzagged +facility +shun +refinish +nutlike +clause +dock +bulldog +grafting +dress +untamed +cheating +willing +ecosphere +excluding +yeah +residence +thrive +alive +sultry +persuader +projector +esquire +huntress +pesticide +retreat +sports +irrigate +chasing +scrabble +unselfish +cognitive +washer +grumpily +exhume +trident +unfreeze +accent +sterility +unenvied +impaired +frail +flyable +parakeet +outright +translate +bats +triceps +jolliness +extradite +sandstone +deserve +matchbox +bargraph +spectacle +implosive +goldsmith +chatting +ship +haphazard +uncooked +wizard +oops +blurb +sessions +dexterity +viability +nail +grief +suburb +statue +trimester +shanty +clarify +aware +caregiver +starving +footsie +sneeze +excursion +clanking +unstaffed +confiding +flatfoot +confidant +registry +emptier +array +unelected +flatness +emblem +yearbook +canon +gallon +example +plaster +unkind +grudge +tweezers +surname +preplan +ivory +undead +unafraid +zone +brim +hatchery +rimless +ravage +remark +kabob +twisting +juncture +residue +almanac +yearling +gab +sizzling +livestock +wharf +trading +wagon +divided +tanned +visibly +recycling +unease +isolated +maximize +employee +party +tusk +gift +passion +marital +cane +reheat +cost +ground +emote +flyer +carried +chair +violator +prototype +cozy +thousand +placard +umbrella +starship +unclothed +legal +backspin +handwash +delicacy +shrapnel +kangaroo +registrar +evaluate +shelter +premises +primer +hacked +fountain +creed +throwing +friday +robin +editor +gainfully +sanctity +given +chirpy +coliseum +rewind +atlantic +flagstick +definite +fever +rumbling +anatomist +straw +studio +gizmo +proofread +impotency +unhappily +devalue +rectangle +groove +icy +salaried +snort +fit +thaw +shorthand +broadways +shortness +shape +tackiness +chute +stiffly +twiddling +crafty +affirm +paddling +water +hardly +cinnamon +slate +mundane +sift +halved +prism +gills +jubilant +carload +difficult +profile +reuse +swagger +broken +monopoly +spoof +riot +giant +passover +curing +august +cartoon +scorch +chuck +varying +germicide +tribute +copier +clubbed +ointment +ricotta +previous +mutt +turtle +astride +unspoken +unjustly +washtub +cresting +pantomime +frostily +skimming +semester +generic +excavate +decidable +stank +vanquish +stack +reverend +pasted +curve +spindle +ashy +runaround +couch +sniff +untimely +traverse +carless +fragrance +affecting +reemerge +roundish +olive +snowy +hazily +aqueduct +carwash +revoke +chamomile +veteran +pacifism +stress +thing +stereo +excavator +whacky +aneurism +dreamily +erratic +untie +grid +repurpose +cleaver +sprig +unseeing +lid +lapel +headlock +untoasted +heading +sandblast +commence +docile +skincare +retaining +crewman +tycoon +sneezing +crazily +roundness +justly +portable +peculiar +amplifier +cloak +smoked +anemia +anaerobic +majestic +puritan +creasing +gladiator +catfish +echo +safeness +turbojet +engraver +disarm +football +elitism +playmate +sharpie +carnation +rubbed +daycare +penpal +eject +amniotic +kinship +buffer +uncertain +document +clumsily +vintage +engaging +sloppy +tidbit +unrevised +cough +qualified +transform +chest +unnoticed +excitable +mowing +thicket +superglue +sanitizer +vagrancy +sleek +aghast +refresh +constant +shown +mothproof +periscope +parachute +kebab +improvise +vacancy +jackal +unburned +nebula +deviation +script +trilogy +jittery +siamese +rinse +jazz +subpanel +expert +illicitly +spent +staleness +rebate +opulently +elastic +poplar +clapper +grime +scraggly +subdued +repugnant +fax +net +tricky +presoak +neutron +expectant +crisply +caramel +simile +duller +fidelity +press +armed +reliance +arrange +happy +corporate +perpetual +hatless +creamer +joyfully +wimp +quickly +easing +cold +mortally +flashing +spool +varied +distrust +squirt +buffing +praying +hug +oversold +dwelling +cuddle +wanted +mumble +crowbar +paying +joylessly +outlook +setting +sabotage +dismantle +prudishly +marbled +sadden +equipment +musty +litigator +evaporate +prologue +crazy +judicial +perjury +ankle +palm +uncurious +grappling +move +pandemic +dingbat +mongrel +eligible +brunt +glacial +hurry +guiding +january +jokingly +cheesy +yogurt +vertical +bluff +starved +fancied +mothball +playback +overrun +exorcism +overbid +next +giblet +nest +darwinism +spotting +doorbell +subtly +scruffy +overview +uncouth +map +gleaming +siren +october +scallop +nastiness +snowless +citrus +squiggly +daylight +epidermis +cope +skittle +deviant +grinch +antonym +ceremony +snare +lunchbox +gothic +exporter +endpoint +ideology +mule +unaware +unwelcome +provider +dirtiness +taunt +wreath +facedown +violation +detract +mutilator +gizzard +perceive +railroad +marbles +playhouse +backdrop +maggot +sherry +gnat +plural +backpedal +gloating +gorged +matriarch +spooky +proving +snide +deity +contour +venomous +liking +upstate +niece +robbing +foam +finally +bannister +tasting +oozy +overstay +spearhead +stubble +salute +herald +unwitting +plating +strobe +curled +constrict +ahead +uncoated +underwent +barley +jugular +unbridle +hamburger +stumbling +drainer +wow +hurt +extortion +glorified +gating +scientist +occupancy +buckwheat +prelaunch +vigorous +twerp +passcode +tavern +amuser +dizzy +try +crease +darkening +flattery +gestation +coauthor +activism +evasion +capped +shame +turkey +sarcasm +grandma +squeezing +nickname +resigned +handsaw +gem +upgrade +retool +plank +clavicle +afflicted +line +guru +unsheathe +resisting +replace +lumping +bagginess +unlawful +defender +womanly +crudeness +smugly +blunderer +freeness +tightwad +answering +challenge +thievish +purging +component +favoring +bullseye +tibia +trillion +lubricate +mural +unroll +applicant +devourer +job +sinner +hypnotize +valley +dugout +shrill +paralegal +cycling +backlands +down +saucy +pusher +cubbyhole +consuming +growing +grumbling +tuition +film +amount +powdering +reptile +sprinkled +kerchief +coastal +autistic +calamity +glucose +unimpeded +submarine +starboard +styling +envision +yen +cabdriver +pardon +scroll +directive +balmy +timing +linseed +unglue +aide +specimen +untying +viper +leggings +vindicate +uptown +recent +junkman +freefall +slip +petal +saddlebag +hurried +early +coat +rockslide +tricycle +grab +crusader +bucked +washroom +skinless +deduct +maternity +entwine +squishier +spoiled +hatchback +dragging +preamble +snowdrift +designate +panda +surplus +pueblo +endless +unmapped +reconcile +blazing +glove +unclad +slander +snap +feline +overthrow +research +shrubs +landfill +liqueur +mankind +suave +absolve +gender +define +strangle +relocate +credibly +apprehend +fifth +confound +sagging +imperial +tributary +grub +urethane +parsley +geranium +overflow +return +subsector +ultra +dowry +levers +sculptor +accustom +headrest +stout +eats +overcome +unsubtle +negligee +unstopped +trombone +agnostic +haggler +unpaved +strike +untaken +ageless +unstable +cruncher +deftly +culpable +ashes +reproach +navigator +tipoff +evasive +removing +blurt +whoops +dropout +aground +curable +pureblood +john +various +baking +lunchroom +frigidity +amiable +refreeze +unequal +karaoke +jumbo +suspect +celtic +stopper +passably +rascal +legged +tapering +puppet +prompter +hunchback +padlock +storeroom +briar +squad +spied +negotiate +traffic +unstylish +amplify +granny +subwoofer +angled +stroller +paced +flanked +swinging +shelving +streak +gown +collie +badass +spirited +hydrogen +underpaid +polar +dallying +armband +frolic +ventricle +affront +cure +grating +keenness +tabasco +depraved +manhood +dispute +implosion +jailhouse +aversion +shrank +sulk +nearest +outboard +size +reversing +almost +unshaken +encircle +stipulate +rocky +arson +endurance +suction +walmart +juggle +hurled +capable +mountain +calm +goggles +bulgur +studied +obtrusive +ninetieth +dwarf +doctrine +quilt +outward +automated +issue +saline +motivator +overfill +dynasty +yo-yo +resistant +dove +brunch +rendition +freebee +exorcist +moonscape +embargo +oversleep +tidiness +ammonia +predict +rely +huntsman +manager +relock +detergent +seizing +enactment +obstacle +divinity +trash +capsize +untagged +blend +panning +anagram +anymore +stratus +engulf +empathic +antacid +dime +handwrite +lustrous +lifting +mug +elevating +bungee +squeeze +broiler +spyglass +curly +exerciser +outmost +corroding +uneatable +bullish +defeat +isotope +rule +sitting +narrow +mortified +average +applied +pecan +reprocess +spearfish +captive +mortality +dreamless +disdain +daylong +boat +caddy +swarm +pavilion +bush +respect +swimwear +errand +truce +unlined +blemish +explicit +glutinous +casually +nursing +preschool +posting +monogamy +selector +purifier +trench +escapist +aged +hurler +unedited +acrobat +unpinned +walrus +monotone +theater +tarmac +volumes +pushiness +reapprove +cryptic +used +chaplain +hurricane +harmonics +womanhood +sensitive +gotten +dweeb +borrower +prevalent +agent +hungrily +unread +truth +salsa +dynamite +exuberant +bazooka +draw +scolding +uncaring +frame +spud +polygon +unwary +waking +hurdle +repeated +backspace +dodgy +unadorned +property +rehab +headgear +family +raking +rimmed +unclip +matter +coveted +ambitious +prewar +cautious +bulge +auction +jockstrap +demystify +grader +pawing +aptly +throttle +jogger +stylized +crystal +ascent +evict +trough +unvaried +stark +overtime +unsold +cradle +ambition +satin +album +wrinkly +bullion +coziness +awoke +district +unaired +entertain +dramatic +hyperlink +flyover +yin +revert +scavenger +batboy +legislate +recollect +stamina +driving +old +kosher +protract +dole +cradling +flavored +aspirin +ladies +affluent +abiding +overturn +undermine +science +disperser +dilute +uncork +change +synopsis +silenced +falsify +flint +consonant +rewash +peso +secular +reseller +annotate +overripe +slam +scheme +unlaced +knickers +jogging +ditch +corridor +chase +petted +molehill +user +radar +petition +census +reborn +exalted +graveyard +congenial +dreadful +budding +capably +wand +pulp +childless +most +splendid +module +outsider +stoppable +stick +hydroxide +snaking +reconfirm +version +unsworn +cornbread +stride +tables +cusp +remarry +robust +stooge +elevate +numbly +diminish +severity +blissful +chunk +litmus +glowworm +rented +handoff +eatable +spouse +sustained +disband +greedy +exchange +harmony +annually +hate +spousal +drop-down +shadiness +gecko +broaden +acorn +skeleton +sturdily +doorpost +persuaded +shortcut +agency +repulsive +spiny +outcome +boasting +swept +neglector +halogen +exes +supremacy +crescent +phoney +unabashed +rink +sandpit +cacti +stylishly +jingling +art +heat +basics +stem +riddance +covenant +nanometer +keg +dinner +reviver +nicotine +dandy +caucus +euphemism +shortlist +turbofan +gulp +mummify +decal +trace +drapery +survey +boxing +subarctic +nearby +lividly +dreaded +constable +duly +wobble +handprint +disorder +camcorder +composite +handshake +purplish +growl +janitor +boundless +immovable +uninjured +stopping +disloyal +poster +heaving +avoid +resize +gopher +coveting +overpass +jelly +duration +galvanize +fantasy +aim +crisped +molecular +carpentry +debit +awkward +skinny +celestial +overlook +numerator +running +ashamed +amber +hyphen +luster +clock +bronzing +punctured +pretended +curvy +detective +twitter +tiptoeing +legwarmer +corsage +tadpole +flaxseed +buggy +obligate +handset +irritant +nugget +sheep +marina +alongside +unlocked +humiliate +viewless +outing +skewer +capsule +reversion +regally +threefold +preheated +resilient +carport +suggest +commotion +deflator +displease +settle +anger +unsteady +unmoral +cleat +squabble +decompose +brush +fondling +perish +wobbling +revival +dosage +disparity +brussels +boots +civil +container +shed +raft +cheddar +bride +mousy +wildfire +dude +rejoin +resonant +passenger +customary +outweigh +degrease +delusion +encore +declared +uncurled +implicate +unpack +hybrid +twitch +penholder +exclusion +outmatch +onboard +knee +epidemic +tubular +flier +silicon +creation +fantastic +glorify +worrisome +glaring +stabilize +ungraded +voicing +unlivable +demotion +sacrifice +animal +freeway +motto +smuggler +utopia +gulf +passive +reclaim +aflame +heave +septum +safely +cramp +emotion +hunk +retouch +equation +catsup +brisket +condition +numerous +porridge +backtrack +synthetic +steep +surfer +busybody +ripening +bath +blurry +bouncing +ample +catapult +tactile +surpass +bartender +antler +cobbler +arming +envelope +undrilled +clamp +direction +syndrome +setup +charbroil +yoga +shaking +chastise +coeditor +overpower +cover +frisbee +venture +duplicate +scalding +coconut +clutter +eggnog +gumdrop +sectional +prissy +hammock +tipping +ethanol +deskbound +suitable +symphony +clustered +pupil +cobalt +path +credit +unlighted +backstab +gigabyte +startle +eclipse +snorkel +shimmy +ozone +compel +cardboard +bodacious +slush +submerge +embody +concerned +mandate +surgery +relapsing +hula +length +greeter +spherical +lugged +slouchy +unknowing +quaking +prude +subtitle +manual +maternal +chaps +surprise +manhole +cache +handgrip +footwork +attention +detoxify +clobber +turret +barge +smolder +parasite +feel +opposing +deplored +ruse +guide +unlatch +posted +crumb +portion +smartly +purify +unworn +tapioca +snowbound +cheek +enforced +badness +breath +maximum +control +backing +shank +shrouded +circulate +remedy +shakily +creamlike +blubber +fragrant +panhandle +sesame +importer +spoiler +glitch +caterer +uncanny +diary +florist +surely +shortwave +blinks +smudgy +zesty +genetics +unloader +obstinate +perfected +profanity +tapestry +footless +cursor +these +spoils +wrongly +onset +defraud +unfiled +struck +coronary +disengage +heavily +slaw +dorsal +clunky +dawn +lip +throng +service +hatbox +dastardly +eastcoast +ragweed +squall +groggily +cushy +carnival +nullify +subtotal +scrubbed +debate +unison +shine +prescribe +reformist +jiffy +fondness +preformed +napped +overhaul +claw +ellipse +cruelness +hardener +carpool +unglazed +uniformly +unmanaged +enclose +viewable +fastball +drainable +exemplify +lens +implement +splurge +unhidden +imply +survival +cricket +cringe +usual +capture +automatic +glimpse +visitor +hatchet +unthread +egomaniac +fifty +fox +resident +hangup +wisdom +sneak +configure +depletion +eccentric +sly +disinfect +backlit +mummified +kimono +voltage +rewrap +gazing +swoosh +ribbon +pliable +expedited +slouching +national +bolster +steerable +backshift +smog +earthlike +mulled +arrogance +reclusive +essay +oxidant +moustache +goes +sludge +tightrope +washable +unwanted +bogged +wreckage +precut +implant +dumpling +dreamboat +tracing +promoter +dipper +fracture +attempt +seismic +extending +eldercare +coastline +garnet +democracy +boil +magical +cupcake +finance +irritable +synergy +coherence +dealt +collage +foe +pacifier +bobtail +unblessed +chamber +activity +pacific +striking +oblong +endorphin +handpick +smartness +cosmos +radio +ladylike +prize +sitter +estimator +prone +childish +refinery +cod +untrimmed +earplugs +afterlife +unearth +confusion +stability +arousal +stuffed +regular +remedial +enlarged +unvented +phrasing +gristle +moonrise +bunch +motocross +defrost +untangled +quintuple +cedar +unlovable +reverence +parking +clover +depose +resample +recreate +feast +overbook +reattach +purebred +hence +gallery +yonder +unshipped +finicky +pushover +gratified +attic +ipad +shining +barstool +undertow +pronounce +nimbly +relay +headway +mannish +secluded +populate +cosigner +cathedral +unreeling +skintight +landlord +falcon +snuggle +sandbox +ream +disbelief +roman +pendant +timothy +calcium +crablike +kindle +lint +sculpture +eraser +limeade +arrival +nastily +agonizing +salutary +enroll +swivel +darn +confined +frugality +enticing +giddiness +pouring +avatar +swell +humility +deception +liberty +flatterer +peddling +junior +rebuild +diffuser +unloving +napping +unholy +unwatched +reunion +refried +evident +dumping +mockup +shaft +construct +linked +posture +pranker +equal +stargazer +ravioli +gerbil +headwear +guzzler +refute +shale +overbite +contort +headpiece +morality +malformed +kleenex +clumsy +pouch +mower +alias +driveway +trailing +unskilled +commode +quotable +mournful +huddle +ammonium +worshiper +masses +debating +ride +unseemly +stunt +spry +itinerary +glamorous +bagged +stoplight +zeppelin +unbundle +granular +humid +repeater +garland +battalion +luridness +plod +untruth +glorious +widen +reoccur +subprime +boring +reaction +pond +pox +itunes +shampoo +speller +datebook +blot +racism +labrador +pelvis +casino +handmade +mocker +lively +operating +vividness +thumb +outshoot +preset +penknife +twig +glider +versus +childcare +motivate +zoning +halves +oval +jam +slashed +wrench +squeegee +steering +dragster +fiction +cranial +feeble +chewing +splinter +vineyard +rearrange +clarity +marathon +exponent +prideful +zebra +shelve +prominent +leotard +drainage +snack +gravel +remix +nearly +ooze +prognosis +march +mosaic +simple +delirium +recall +apostle +schilling +kissing +wistful +aqua +grip +outwit +dazzling +gigantic +criteria +gibberish +fanciness +ripping +unplug +washing +luckily +dragonish +rotting +neuter +corrosive +shopper +cornea +dumpster +delivery +trophy +library +shrink +bully +target +wincing +duvet +iphone +retread +bounce +filler +presume +landlady +portly +upstream +disrupt +steadfast +undone +tarnish +politely +sixtyfold +sensation +scant +clique +consumer +unopened +bleach +happily +zombie +composed +overlap +sway +scotch +embattled +frequent +canyon +barrier +acquire +debtor +kisser +aviation +rearview +skied +appendage +maybe +derby +disgrace +wolf +overhand +those +dazzler +spoiling +reentry +tiny +bullfight +washout +renewal +venus +pushy +tuesday +avalanche +bubble +uncheck +uniformed +unwilling +zealous +blighted +lanky +delta +dolphin +prorate +snowplow +relish +sliding +swan +unselect +subplot +stung +nag +pessimist +relight +moonbeam +sighing +extended +rebound +sleep +foothill +ended +prancing +clench +coil +erupt +outlying +dating +ipod +lilly +lukewarm +cadet +equity +uptake +lash +flaring +copied +exclaim +bash +sulphuric +backstage +unwired +siesta +pursuable +stash +improve +marlin +imagines +saggy +ice +tacking +scribing +unpaired +abstain +stimuli +scone +banshee +unmoved +patient +yield +yapping +satisfied +sardine +kettle +riverboat +subheader +pushpin +juggling +reconvene +poise +uncouple +gathering +frisk +dissuade +elite +bagel +wake +disarray +overkill +aids +evaluator +mummy +drinkable +tropical +vitalize +exact +concave +boss +goatskin +crummiest +wages +wielder +avid +mouth +caress +professed +barbecue +paper +everglade +dealmaker +dedicate +paprika +lethargy +haunt +tribune +enviable +hydration +stretch +baked +uncombed +doorframe +harness +aorta +freemason +trousers +cupid +catering +femur +wrongful +pout +departed +sequel +surface +regime +rename +festival +wish +stage +gloater +unleash +transpose +drench +commuting +affix +occultist +stunning +partridge +sanded +sublime +spoken +slimy +grieving +unframed +lining +remember +utilize +skater +mastiff +trowel +renewed +monologue +carpenter +gargle +undergo +hardship +crayfish +condiment +frayed +unretired +hatred +another +almighty +astronaut +domelike +ramble +clash +barometer +jalapeno +carol +abroad +panorama +stonewall +scouts +destruct +unengaged +runt +defense +sweep +backlog +dairy +deceptive +luncheon +rubbing +slapstick +ranking +graceful +slackness +brink +senator +remindful +bunkbed +untrue +roulette +taekwondo +santa +unsmooth +scholar +blaming +fragile +abridge +moistness +from +manor +outburst +stadium +twentieth +staunch +epiphany +keep +unrivaled +reverb +twiddle +justifier +removal +flavoring +tranquil +lumpiness +object +unrobed +stuffing +curse +pelican +decade +travel +theme +attach +ought +during +repressed +smokiness +washbowl +ethics +connector +scoring +trusting +prozac +dismiss +varmint +resort +licorice +spectrum +enviably +harmonica +undefined +anatomy +baggie +earthen +plaything +deluge +savor +hydrated +filth +reselect +jaywalker +jeep +backup +matted +excretion +blooper +riverbank +sediment +catwalk +scurvy +chess +pelt +barbed +edging +gilled +provoke +statistic +reforest +doorstep +unethical +cartload +dollhouse +churn +junction +whiny +ablaze +recant +canister +mama +nativity +facebook +federal +corral +unsealed +squatting +entail +estate +confusing +eligibly +cabbie +browsing +arrogant +boxer +unmatched +semisoft +dental +enslave +yummy +onyx +henchman +veggie +glutton +chariot +playful +racoon +garlic +skating +denture +elbow +reanalyze +mashed +faceless +avert +shorts +operable +denial +asparagus +certainly +domain +unbiased +earmuff +subsiding +mushiness +patchwork +playgroup +petroleum +backside +gaffe +trembling +magnifier +similarly +untrained +parade +parcel +cider +uncorrupt +wronged +much +unmanned +mushroom +snitch +semicolon +dealer +parting +recital +congrats +anchovy +curfew +excusable +gnarly +breeching +paramedic +zips +swampland +exemption +absinthe +simplify +thwarting +stole +reenter +manmade +overstep +concur +rural +varsity +trustless +salvage +hardiness +armchair +handling +reporter +cannabis +thermal +degree +imprecise +semifinal +flammable +factor +unrushed +ruby +spleen +starless +observing +ember +sullen +undertake +golf +safeguard +duke +spinout +transfer +linguini +pregnant +stupor +unfrosted +prewashed +cranium +sixfold +lucid +chapped +enjoyably +privatize +blooming +boozy +earphone +armrest +affidavit +distance +unisexual +posing +humbling +chapter +partner +shrine +imitation +retinal +pension +vivacious +drift +substance +nutshell +disparate +referable +residual +catching +manliness +fidgeting +hazing +easiest +lather +hunger +stomp +emperor +overboard +humorless +stand +although +polygraph +shimmer +illusion +oblivion +anybody +amuck +coroner +probation +unwed +revisit +gallows +shaky +liable +tactful +pettiness +satchel +sandstorm +retiree +deuce +enlarging +tabloid +crunchy +plexiglas +slept +pyramid +massive +snowman +thrower +deceit +grout +guy +outshine +agenda +regress +cape +synthesis +unleaded +oboe +empirical +turban +popular +swapping +mating +flight +manned +mollusk +renounce +fringe +mandolin +scorpion +dealing +stony +tingle +curler +unmarked +imitate +gurgle +haiku +privacy +vowel +treat +kiln +dispersal +unfocused +duckbill +reunite +mustiness +wispy +botany +hazard +compactly +confident +resupply +unbroken +repent +laziness +undivided +recoil +unhook +swinger +gumming +monoxide +policy +thirsting +bankbook +diagram +cardigan +headfirst +kindred +dimly +banking +angler +imprudent +second +cupping +crewless +numbness +geriatric +cosmetics +poncho +handful +payable +twirl +commute +overexert +cocoa +screen +uncle +borough +untreated +designer +doorknob +wildcat +shout +veneering +parrot +french +slang +borax +trailside +untapped +student +obituary +tarot +prowling +scam +splatter +suffrage +mayflower +wasp +paralyze +radiance +overlay +mystify +sternness +rash +candied +sulfate +remold +protozoan +banker +cubical +antarctic +carnage +marrow +endanger +divisibly +gloomy +wildland +legume +earthling +pureness +eggplant +broadcast +uncut +duplex +deport +diving +borrowing +plot +cultivate +widely +plutonium +hypocrisy +splashy +gently +refill +salad +drizzle +visiting +silk +deeply +outflank +revered +eel +likely +saloon +blast +graveness +stock +overuse +lapped +sincere +private +kilometer +pectin +manpower +regroup +grill +preteen +froth +nuptials +unsnap +payroll +bony +detail +cameo +crispness +crested +rope +until +refund +record +duffel +annex +duo +subsystem +neurosis +rebel +latter +dining +tartar +upcoming +creamed +unsubtly +creative +acetone +immerse +grazing +steam +lushness +track +crook +native +cucumber +shallow +sulfur +unloved +upturned +reliably +stinking +desktop +blandness +scabby +gurgling +purposely +likewise +giddily +reapply +resurrect +untold +paralysis +sprawl +defile +unhearing +degrading +wilt +outfit +recognize +that +street +subside +dormitory +jaws +curling +evade +amid +stricken +stellar +unheated +emerald +juror +entangled +clatter +unloaded +gestate +throbbing +unclamped +symphonic +lunchtime +morale +ungloved +playlist +husked +laundry +breeze +fleshed +poppy +tackling +laborer +product +issuing +unscathed +goldfish +stinky +persevere +flick +lard +stallion +race +imposing +subfloor +finisher +gooey +blah +twisted +swimsuit +ungreased +catchy +putdown +unhappy +unheard +eastbound +coasting +reptilian +plethora +clerk +shindig +slighting +thesis +utmost +dividers +unwoven +cupped +plausibly +deploy +ebay +underwire +aeration +pried +drowsily +agony +untitled +vocally +diffusion +disposal +sputter +life +cubicle +scarring +sinuous +dividend +glitzy +blush +popper +quadrant +throwback +tinsel +maturity +unfold +twice +freedom +ribcage +fence +eleven +providing +platform +upheld +submersed +slab +bundle +villain +front +relic +muzzle +removable +demeanor +tilt +clamshell +daytime +headsman +cornfield +nutcase +excusably +numeric +worst +banana +exploit +preface +kerosene +prankish +chill +resurface +eloquence +hardcore +stardom +reflux +edition +outspoken +copilot +scion +retrieval +hash +wick +expansion +outnumber +context +regalia +bouncy +epic +vanish +pacemaker +hertz +serotonin +output +trimness +unworldly +pasture +monthly +cornmeal +outgoing +landing +cavalry +rebuilt +phosphate +duh +urchin +monorail +boned +smoking +finalist +dolly +thank +overdrive +presuming +passing +tyke +cottage +antiques +curdle +thumping +cut +tremor +headroom +charging +festivity +sample +chewer +wool +virtual +fetch +kindness +fancy +residency +unharmed +bountiful +strive +strung +elope +capillary +morphing +ogle +celibacy +vaporizer +reshape +fraction +ascension +mangle +bonehead +recolor +doorstop +quail +strudel +frighten +repost +swimmer +securely +devotion +pavement +hardhead +unrated +broadband +wiring +closure +retrace +customer +deprive +repackage +agile +girdle +saga +crank +lung +headstone +filing +skeletal +felt-tip +country +botanist +purgatory +copper +tamer +monsieur +unscrew +wheat +confider +jaunt +reviving +unfazed +ajar +eating +sprint +colonist +reps +gyration +brigade +tingly +pyromania +harbor +mumbo +catatonic +shove +whoopee +straddle +exfoliate +dictation +coaster +oxidation +aside +tiger +cancel +aspect +playable +rebuff +rebalance +embroider +empty +mounted +outfield +scale +preachy +exert +sedate +award +retired +confused +sterilize +computer +unleveled +yarn +recapture +renovator +evacuee +utensil +bobble +starlet +kilowatt +shirt +living +powwow +darkness +roster +lagoon +rematch +edge +hunting +pampers +coagulant +overture +custard +murkiness +parabola +bronze +causing +abide +unreal +untwist +hankering +upbeat +crimp +reputably +procedure +deflation +figment +trump +botch +unmade +gimmick +opponent +counting +overeater +bulginess +neatly +sworn +spoon +richly +comic +hush +perm +discover +skimpily +unsettled +cycle +drastic +hardcopy +spirits +ounce +undertone +canary +pebble +reprogram +boggle +diligence +composure +perfectly +atypical +rotten +matchbook +attentive +payday +pushchair +rise +huskiness +appointee +courier +cognition +coke +fiftieth +spur +audience +flashcard +lyrics +regulator +diabetic +limelight +craziness +unmixed +popcorn +manly +camisole +thimble +boxy +dipping +glory +evergreen +shady +umpire +subtext +division +gong +resonate +endocrine +italics +uneasy +valium +doze +polymer +handler +fame +disjoin +unviable +talisman +reabsorb +magnetize +purge +habitual +angling +plenty +identify +kindly +fritter +cornstalk +veal +upwind +craving +rut +oink +uncross +sizing +favorite +purist +vaseline +quicken +expand +daredevil +jersey +unbitten +catty +playmaker +multitude +spruce +barrette +cesarean +profusely +luminous +fled +faceplate +talcum +mace +chewable +swifter +jasmine diff --git a/db.sh b/db.sh new file mode 100755 index 000000000..bb9685550 --- /dev/null +++ b/db.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env -S bash -xe + +stack build --fast --flag uniworx:library-only --flag uniworx:dev +stack exec uniworxdb -- $@ diff --git a/ghci.sh b/ghci.sh index 5139c7c72..77391583f 100755 --- a/ghci.sh +++ b/ghci.sh @@ -7,13 +7,13 @@ export DUMMY_LOGIN=true move-back() { mv -v .stack-work .stack-work-ghci - [[ -d .stack-work-run ]] && mv -v .stack-work-run .stack-work + [[ -d .stack-work-build ]] && mv -v .stack-work-build .stack-work } if [[ -d .stack-work-ghci ]]; then - [[ -d .stack-work ]] && mv -v .stack-work .stack-work-run + [[ -d .stack-work ]] && mv -v .stack-work .stack-work-build mv -v .stack-work-ghci .stack-work trap move-back EXIT fi -stack ghci --flag uniworx:dev --flag uniworx:library-only +stack ghci --flag uniworx:dev --flag uniworx:library-only ${@:-uniworx:lib} diff --git a/haddock.sh b/haddock.sh new file mode 100755 index 000000000..aaceeb329 --- /dev/null +++ b/haddock.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +exec -- stack build --fast --flag uniworx:library-only --flag uniworx:dev --haddock --haddock-hyperlink-source --haddock-deps --haddock-internal diff --git a/hlint/Hlint.hs b/hlint/Hlint.hs new file mode 100644 index 000000000..857467823 --- /dev/null +++ b/hlint/Hlint.hs @@ -0,0 +1,4 @@ +{-# OPTIONS_GHC + -F -pgmF hlint-test + -optF src + #-} diff --git a/messages/button/de.msg b/messages/button/de.msg new file mode 100644 index 000000000..de25fb0c6 --- /dev/null +++ b/messages/button/de.msg @@ -0,0 +1,3 @@ +AmbiguousButtons: Mehrere Submit-Buttons aktiv +WrongButtonValue: Submit-Button hat falschen Wert +MultipleButtonValues: Submit-Button hat mehrere Werte \ No newline at end of file diff --git a/messages/dummy/de.msg b/messages/dummy/de.msg index f3ca7cae1..5a24922aa 100644 --- a/messages/dummy/de.msg +++ b/messages/dummy/de.msg @@ -1 +1,2 @@ -DummyIdent: Nutzer-Kennung \ No newline at end of file +DummyIdent: Nutzer-Kennung +DummyNoFormData: Keine Formulardaten empfangen \ No newline at end of file diff --git a/messages/uniworx/de.msg b/messages/uniworx/de.msg index 1bd1ddd42..7fa4c8bce 100644 --- a/messages/uniworx/de.msg +++ b/messages/uniworx/de.msg @@ -7,6 +7,7 @@ BtnHijack: Sitzung übernehmen Aborted: Abgebrochen Registered: Angemeldet +RegisteredSince date@Text: Angemeldet seit #{date} RegisterFrom: Anmeldungen von RegisterTo: Anmeldungen bis DeRegUntil: Abmeldungen bis @@ -16,7 +17,7 @@ WinterTerm year@Integer: Wintersemester #{display year}/#{display $ succ year} SummerTermShort year@Integer: SoSe #{display year} WinterTermShort year@Integer: WiSe #{display year}/#{display $ mod (succ year) 100} PSLimitNonPositive: “pagesize” muss größer als null sein -Page n@Int64: #{display n} +Page num@Int64: #{display num} TermsHeading: Semesterübersicht TermCurrent: Aktuelles Semester @@ -28,6 +29,16 @@ InvalidInput: Eingaben bitte korrigieren. Term: Semester TermPlaceholder: W/S + vierstellige Jahreszahl +TermStartDay: Erster Tag +TermStartDayTooltip: Üblicherweise immer 1.April oder 1.Oktober +TermEndDay: Letzter Tag +TermEndDayTooltip: Üblicherweise immer 30.September oder 31.März +TermLectureStart: Beginn Vorlesungen +TermLectureEnd: Ende Vorlesungen +TermLectureEndTooltip: Meistens dauer das Sommersemester 14 Wochen und das Wintersemester 15 Wochen. +TermActive: Aktiv + + SchoolListHeading: Übersicht über verwaltete Institute SchoolHeading school@SchoolName: Übersicht #{display school} @@ -38,6 +49,7 @@ CourseShort: Kürzel CourseCapacity: Kapazität CourseCapacityTip: Falls angegeben wird die Anzahl an Kursanmeldungen, die zugelassen werden, beschränkt CourseNoCapacity: In diesem Kurs sind keine Plätze mehr frei. +CourseNotEmpty: In diesem Kurs sind momentan Teilnehmer angemeldet. CourseRegisterOk: Sie wurden angemeldet CourseDeregisterOk: Sie wurden abgemeldet CourseSecretWrong: Falsches Kennwort @@ -68,9 +80,13 @@ CourseSemester: Semester CourseSchool: Institut CourseSchoolShort: Fach CourseSecretTip: Anmeldung zum Kurs erfordert Eingabe des Passworts, sofern gesetzt -CourseRegisterFromTip: Ohne Datum ist keine Anmeldung möglich +CourseRegisterFromTip: Ohne Datum ist KEINE eigenständige Anmeldung von Studierenden möglich CourseRegisterToTip: Anmeldung darf auch ohne Begrenzung möglich sein CourseDeregisterUntilTip: Abmeldung darf auch ohne Begrenzung möglich sein +CourseFilterSearch: Volltext-Suche +CourseFilterRegistered: Registriert +CourseDeleteQuestion: Wollen Sie den unten aufgeführten Kurs wirklich löschen? +CourseDeleted: Kurs gelöscht NoSuchTerm tid@TermId: Semester #{display tid} gibt es nicht. NoSuchSchool ssh@SchoolId: Institut #{display ssh} gibt es nicht. @@ -86,11 +102,15 @@ SheetTitleNew tid@TermId ssh@SchoolId csh@CourseShorthand SheetEditHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{display tid}-#{display ssh}-#{csh} #{sheetName} editieren SheetEditOk tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: Übungsblatt #{sheetName} aus Kurs #{display tid}-#{display ssh}-#{csh} wurde gespeichert. SheetNameDup tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: Es gibt bereits ein Übungsblatt #{sheetName} in diesem Kurs #{display tid}-#{display ssh}-#{csh}. -SheetDelHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{sheetName} wirklich aus Kurs #{display tid}-#{display ssh}-#{csh} herauslöschen? -SheetDelText submissionNo@Int: Dies kann nicht mehr rückgängig gemacht werden! Alle Einreichungen gehen ebenfalls verloren! Es gibt #{display submissionNo} Abgaben. +SheetDelHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{sheetName} wirklich aus Kurs #{display tid}-#{display ssh}-#{csh} herauslöschen? Alle assoziierten Abgaben und Korrekturen gehen ebenfalls verloren! SheetDelOk tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{display tid}-#{display ssh}-#{csh}: #{sheetName} gelöscht. +SheetDelHasSubmissions objs@Int: Inkl. #{tshow objs} #{pluralDE objs "Abgabe" "Abgaben"}! + +SheetDeleteQuestion: Wollen Sie das unten aufgeführte Übungsblatt und alle zugehörigen Abgaben wirklich löschen? +SheetDeleted: Übungsblatt gelöscht SheetUploadMode: Abgabe von Dateien +SheetSubmissionMode: Abgabe-Modus SheetExercise: Aufgabenstellung SheetHint: Hinweis SheetHintFrom: Hinweis ab @@ -99,7 +119,7 @@ SheetSolutionFrom: Lösung ab SheetMarking: Hinweise für Korrektoren SheetType: Wertung SheetInvisible: Dieses Übungsblatt ist für Teilnehmer momentan unsichtbar! -SheetInvisibleUntil mFrom@Text: Dieses Übungsblatt ist für Teilnehmer momentan unsichtbar bis #{mFrom}! +SheetInvisibleUntil date@Text: Dieses Übungsblatt ist für Teilnehmer momentan unsichtbar bis #{date}! SheetName: Name SheetDescription: Hinweise für Teilnehmer SheetGroup: Gruppenabgabe @@ -111,6 +131,8 @@ SheetActiveTo: Abgabefrist SheetHintFromTip: Ohne Datum nie für Teilnehmer sichtbar, Korrektoren können diese Dateien immer herunterladen SheetSolutionFromTip: Ohne Datum nie für Teilnehmer sichtbar, Korrektoren können diese Dateien immer herunterladen SheetMarkingTip: Hinweise zur Korrektur, sichtbar nur für Korrektoren +SheetPseudonym: Persönliches Abgabe-Pseudonym +SheetGeneratePseudonym: Generieren SheetErrVisibility: Sichtbarkeit muss vor Beginn der Abgabefrist liegen SheetErrDeadlineEarly: Ende der Abgabefrist muss nach deren Beginn liegen @@ -128,39 +150,54 @@ SubmissionWrongSheet: Abgabenummer gehört nicht zum angegebenen Übungsblatt. SubmissionAlreadyExists: Sie haben bereits eine Abgabe zu diesem Übungsblatt. SubmissionEditHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: #{display tid}-#{display ssh}-#{csh} #{sheetName}: Abgabe editieren/anlegen CorrectionHead tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName cid@CryptoFileNameSubmission: #{display tid}-#{display ssh}-#{csh} #{sheetName}: Korrektur -SubmissionMember g@Int: Mitabgebende(r) ##{display g} +SubmissionMember n@Int: Mitabgebende(r) ##{display n} SubmissionArchive: Zip-Archiv der Abgabedatei(en) SubmissionFile: Datei zur Abgabe SubmissionFiles: Abgegebene Dateien SubmissionAlreadyExistsFor email@UserEmail: #{email} hat bereits eine Abgabe zu diesem bÜbungsblatt. +SubmissionsDeleteQuestion n@Int: Wollen Sie #{pluralDE n "die unten aufgeführte Abgabe" "die unten aufgeführten Abgaben"} wirklich löschen? +SubmissionsDeleted n@Int: #{pluralDE n "Abgabe gelöscht" "Abgaben gelöscht"} + SubmissionGroupName: Gruppenname CorrectionsTitle: Zugewiesene Korrekturen CourseCorrectionsTitle: Korrekturen für diesen Kurs CorrectorsHead sheetName@SheetName: Korrektoren für #{sheetName} +CorrectorAssignTitle: Korrektor zuweisen + Unauthorized: Sie haben hierfür keine explizite Berechtigung. UnauthorizedAnd l@Text r@Text: (#{l} UND #{r}) UnauthorizedOr l@Text r@Text: (#{l} ODER #{r}) +UnauthorizedSiteAdmin: Sie sind kein System-weiter Administrator. UnauthorizedSchoolAdmin: Sie sind nicht als Administrator für dieses Institut eingetragen. +UnauthorizedAdminEscalation: Sie sind nicht Administrator für alle Institute, für die dieser Nutzer Administrator oder Veranstalter ist. UnauthorizedSchoolLecturer: Sie sind nicht als Veranstalter für dieses Institut eingetragen. UnauthorizedLecturer: Sie sind nicht als Veranstalter für diese Veranstaltung eingetragen. UnauthorizedCorrector: Sie sind nicht als Korrektor für diese Veranstaltung eingetragen. UnauthorizedSheetCorrector: Sie sind nicht als Korrektor für dieses Übungsblatt eingetragen. UnauthorizedCorrectorAny: Sie sind nicht als Korrektor für eine Veranstaltung eingetragen. -UnauthorizedParticipant: Sie sind nicht als Teilnehmer für diese Veranstaltung registriert. +UnauthorizedRegistered: Sie sind nicht als Teilnehmer für diese Veranstaltung registriert. +UnauthorizedParticipant: Angegebener Benutzer ist nicht als Teilnehmer dieser Veranstaltung registriert. UnauthorizedCourseTime: Dieses Kurs erlaubt momentan keine Anmeldungen. UnauthorizedSheetTime: Dieses Übungsblatt ist momentan nicht freigegeben. UnauthorizedSubmissionOwner: Sie sind an dieser Abgabe nicht beteiligt. UnauthorizedSubmissionRated: Diese Abgabe ist noch nicht korrigiert. UnauthorizedSubmissionCorrector: Sie sind nicht der Korrektor für diese Abgabe. +UnauthorizedUserSubmission: Nutzer dürfen für dieses Übungsblatt keine Abgaben erstellen. +UnauthorizedCorrectorSubmission: Korrektoren dürfen für dieses Übungsblatt keine Abgaben erstellen. OnlyUploadOneFile: Bitte nur eine Datei hochladen. DeprecatedRoute: Diese Ansicht ist obsolet und könnte in Zukunft entfallen. UnfreeMaterials: Die Materialien für diese Veranstaltung sind nicht allgemein freigegeben. MaterialFree: Kursmaterialien ohne Anmeldung zugänglich UnauthorizedWrite: Sie haben hierfür keine Schreibberechtigung -UnsupportedAuthPredicate tag@String shownRoute@String: "!#{tag}" wurde auf eine Route angewandt, die dies nicht unterstützt: #{shownRoute} +UnauthorizedSystemMessageTime: Diese Systemnachricht ist noch nicht oder nicht mehr einsehbar. +UnauthorizedSystemMessageAuth: Diese Systemnachricht ist nur für angemeldete Benutzer einsehbar. +UnsupportedAuthPredicate authTagT@Text shownRoute@String: "#{authTagT}" wurde auf eine Route angewandt, die dies nicht unterstützt: #{shownRoute} +UnauthorizedDisabledTag authTag@AuthTag: Authorisierungsprädikat "#{toPathPiece authTag}" ist für Ihre Sitzung nicht aktiv +UnknownAuthPredicate tag@String: Authorisierungsprädikat "#{tag}" ist dem System nicht bekannt +UnauthorizedRedirect: Die angeforderte Seite existiert nicht oder Sie haben keine Berechtigung, die angeforderte Seite zu sehen. EMail: E-Mail EMailUnknown email@UserEmail: E-Mail #{email} gehört zu keinem bekannten Benutzer. @@ -171,6 +208,7 @@ AddCorrector: Zusätzlicher Korrektor CorrectorExists email@UserEmail: #{email} ist bereits als Korrektor eingetragen SheetCorrectorsTitle tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: Korrektoren für #{display tid}-#{display ssh}-#{csh} #{sheetName} CountTutProp: Tutorien zählen gegen Proportion +AutoAssignCorrs: Korrekturen am Ende der Abgabefrist automatisch zuteilen Corrector: Korrektor Correctors: Korrektoren CorState: Status @@ -180,8 +218,10 @@ CorByProportionOnly proportion@Rational: #{display proportion} Anteile CorByProportionIncludingTutorial proportion@Rational: #{display proportion} Anteile - Tutorium CorByProportionExcludingTutorial proportion@Rational: #{display proportion} Anteile + Tutorium +RowCount count@Int64: #{display count} #{pluralDE count "Eintrag" "Einträge"} insgesamt DeleteRow: Zeile entfernen ProportionNegative: Anteile dürfen nicht negativ sein +CorrectorUpdated: Korrektor erfolgreich aktualisiert CorrectorsUpdated: Korrektoren erfolgreich aktualisiert CorrectorsPlaceholder: Korrektoren... CorrectorsDefaulted: Korrektoren-Liste wurde aus bisherigen Übungsblättern diesen Kurses generiert. Es sind keine Daten gespeichert. @@ -193,8 +233,10 @@ LoginTitle: Authentifizierung ProfileHeading: Benutzereinstellungen ProfileDataHeading: Gespeicherte Benutzerdaten ImpressumHeading: Impressum +SystemMessageHeading: Uni2work Statusmeldung +SystemMessageListHeading: Uni2work Statusmeldungen -NumCourses n@Int64: #{display n} Kurse +NumCourses num@Int64: #{display num} Kurse CloseAlert: Schliessen Name: Name @@ -210,15 +252,21 @@ MultiFileUploadInfo: (Mehrere Dateien mit Shift oder Strg auswählen) NrColumn: Nr SelectColumn: Auswahl +DBTablePagesize: Einträge pro Seite +DBTablePagesizeAll: Alle CorrDownload: Herunterladen CorrUploadField: Korrekturen CorrUpload: Korrekturen hochladen CorrSetCorrector: Korrektor zuweisen CorrAutoSetCorrector: Korrekturen verteilen -NatField xyz@Text: #{xyz} muss eine natürliche Zahl sein! +CorrDelete: Abgaben löschen +NatField name@Text: #{name} muss eine natürliche Zahl sein! +JSONFieldDecodeFailure aesonFailure@String: Konnte JSON nicht parsen: #{aesonFailure} +SecretJSONFieldDecryptFailure: Konnte versteckte vertrauliche Daten nicht entschlüsseln SubmissionsAlreadyAssigned num@Int64: #{display num} Abgaben waren bereits einem Korrektor zugeteilt und wurden nicht verändert: +SubmissionsAssignUnauthorized num@Int64: #{display num} Abgaben können momentan nicht einem Korrektor zugeteilt werden (z.B. weil die Abgabe noch offen ist): UpdatedAssignedCorrectorSingle num@Int64: #{display num} Abgaben wurden dem neuen Korrektor zugeteilt. NoCorrector: Kein Korrektor RemovedCorrections num@Int64: Korrektur-Daten wurden von #{display num} Abgaben entfernt. @@ -245,10 +293,14 @@ RatingComment: Kommentar SubmissionUsers: Studenten Rating: Korrektur RatingPoints: Punkte +RatingDone: Bewertung sichtbar RatingPercent: Erreicht RatingFiles: Korrigierte Dateien PointsNotPositive: Punktzahl darf nicht negativ sein +PointsTooHigh maxPoints@Points: Punktzahl darf nicht höher als #{tshow maxPoints} sein RatingPointsDone: Abgabe zählt als korrigiert, gdw. Punktezahl gesetzt ist +ColumnRatingPoints: Punktzahl +Pseudonyms: Pseudonyme FileTitle: Dateiname FileModified: Letzte Änderung @@ -261,6 +313,23 @@ RatingUpdated: Korrektur gespeichert RatingDeleted: Korrektur zurückgesetzt RatingFilesUpdated: Korrigierte Dateien überschrieben +RatingNotUnicode uexc@UnicodeException: Bewertungsdatei nicht in UTF-8 kodiert: #{tshow uexc} +RatingMissingSeparator: Präambel der Bewertungsdatei konnte nicht identifziert werden +RatingMultiple: Bewertungen enthält mehrere Punktzahlen für die gleiche Abgabe +RatingInvalid parseErr@String: Bewertungspunktzahl konnte nicht als Zahl verstanden werden: #{parseErr} +RatingFileIsDirectory: Unerwarteter Fehler: Datei ist unerlaubterweise ein Verzeichnis +RatingNegative: Bewertungspunkte dürfen nicht negativ sein +RatingExceedsMax: Bewertung übersteigt die erlaubte Maximalpunktzahl +RatingNotExpected: Keine Bewertungen erlaubt +RatingBinaryExpected: Bewertung muss 0 (=durchgefallen) oder 1 (=bestanden) sein + +SubmissionSinkExceptionDuplicateFileTitle file@FilePath: Dateiname #{show file} kommt mehrfach im Zip-Archiv vor +SubmissionSinkExceptionDuplicateRating: Mehr als eine Bewertung gefunden. +SubmissionSinkExceptionRatingWithoutUpdate: Bewertung gefunden, es ist hier aber keine Bewertung der Abgabe möglich. +SubmissionSinkExceptionForeignRating smid@CryptoFileNameSubmission: Fremde Bewertung für Abgabe #{toPathPiece smid} enthalten. Bewertungen müssen sich immer auf die gleiche Abgabe beziehen! + +MultiSinkException name@Text error@Text: In Abgabe #{name} ist ein Fehler aufgetreten: #{error} + NoTableContent: Kein Tabelleninhalt NoUpcomingSheetDeadlines: Keine anstehenden Übungsblätter @@ -274,6 +343,9 @@ DateFormat: Datumsformat TimeFormat: Uhrzeitformat DownloadFiles: Dateien automatisch herunterladen DownloadFilesTip: Wenn gesetzt werden Dateien von Abgaben und Übungsblättern automatisch als Download behandelt, ansonsten ist das Verhalten browserabhängig (es können z.B. PDFs im Browser geöffnet werden). +NotificationSettings: Erwünschte Benachrichtigungen + +ActiveAuthTags: Aktivierte Authorisierungsprädikate InvalidDateTimeFormat: Ungültiges Datums- und Zeitformat, JJJJ-MM-TTTHH:MM[:SS] Format erwartet AmbiguousUTCTime: Der angegebene Zeitpunkt lässt sich nicht eindeutig zu UTC konvertieren @@ -289,13 +361,14 @@ SubmissionFilesIgnored: Es wurden Dateien in der hochgeladenen Abgabe ignoriert: SubmissionDoesNotExist smid@CryptoFileNameSubmission: Es existiert keine Abgabe mit Nummer #{toPathPiece smid}. LDAPLoginTitle: Campus-Login -PWHashLoginTitle: Uni2Work-Login -PWHashLoginNote: Dieses Formular ist zu verwenden, wenn Sie vom Uni2Work-Team spezielle Logindaten erhalten haben. Normale Nutzer melden sich bitte via Campus-Login an! +PWHashLoginTitle: Uni2work-Login +PWHashLoginNote: Dieses Formular ist zu verwenden, wenn Sie vom Uni2work-Team spezielle Logindaten erhalten haben. Normale Nutzer melden sich bitte via Campus-Login an! DummyLoginTitle: Development-Login CorrectorNormal: Normal CorrectorMissing: Abwesend CorrectorExcused: Entschuldigt +CorrectorStateTip: Abwesende Korrektoren bekommen bei späteren Übungsblättern mehr Korrekturen zum Ausgleich zugewiesen. Entschuldigte Korrektoren müssen nicht nacharbeiten. DayIsAHoliday tid@TermId date@Text: #{date} ist ein Feiertag DayIsOutOfLecture tid@TermId date@Text: #{date} ist außerhalb der Vorlesungszeit des #{display tid} @@ -305,7 +378,248 @@ UploadModeNone: Kein Upload UploadModeUnpack: Upload, einzelne Datei UploadModeNoUnpack: Upload, ZIP-Archive entpacken +SheetNoSubmissions: Keine Abgabe +SheetCorrectorSubmissions: Abgabe extern mit Pseudonym +SheetUserSubmissions: Direkte Abgabe + +SheetCorrectorSubmissionsTip: Abgabe erfolgt über ein Uni2work-externes Verfahren (zumeist in Papierform durch Einwurf) unter Angabe eines persönlichen Pseudonyms. Korrektorn können mithilfe des Pseudonyms später Korrekturergebnisse in Uni2work eintragen, damit Sie sie einsehen können. + SubmissionNoUploadExpected: Es ist keine Abgabe von Dateien vorgesehen. FieldPrimary: Hauptfach FieldSecondary: Nebenfach + +MailTestFormEmail: Email-Addresse +MailTestFormLanguages: Spracheinstellungen + +MailTestSubject: Uni2work Test-Email +MailTestContent: Dies ist eine Test-Email versandt von Uni2work. Von Ihrer Seite ist keine Handlung notwendig. +MailTestDateTime: Test der Datumsformattierung: + +German: Deutsch +GermanGermany: Deutsch (Deutschland) + +MailSubjectSubmissionRated csh@CourseShorthand: Ihre #{csh}-Abgabe wurde korrigiert +MailSubmissionRatedIntro courseName@Text termDesc@Text: Ihre Abgabe im Kurs #{courseName} (#{termDesc}) wurde korrigiert. + +MailSubjectSheetActive csh@CourseShorthand sheetName@SheetName: #{sheetName} in #{csh} wurde herausgegeben +MailSheetActiveIntro courseName@Text termDesc@Text sheetName@SheetName: Sie können nun #{sheetName} im Kurs #{courseName} (#{termDesc}) herunterladen. + +MailSubjectSubmissionsUnassigned csh@CourseShorthand sheetName@SheetName: Abgaben zu #{sheetName} in #{csh} konnten nicht verteilt werden +MailSubmissionsUnassignedIntro n@Int courseName@Text termDesc@Text sheetName@SheetName: #{tshow n} Abgaben zu #{sheetName} im Kurs #{courseName} (#{termDesc}) konnten nicht automatisiert verteilt werden. + +MailSubjectSheetSoonInactive csh@CourseShorthand sheetName@SheetName: #{sheetName} in #{csh} kann nur noch kurze Zeit abgegeben werden +MailSheetSoonInactiveIntro courseName@Text termDesc@Text sheetName@SheetName: Abgabefirst für #{sheetName} im Kurs #{courseName} (#{termDesc}) endet in Kürze. +MailSubjectSheetInactive csh@CourseShorthand sheetName@SheetName: Abgabfrist für #{sheetName} in #{csh} abgelaufen +MailSheetInactiveIntro courseName@Text termDesc@Text sheetName@SheetName: Die Abgabefirst für #{sheetName} im Kurs #{courseName} (#{termDesc}) beendet. + +MailSubjectCorrectionsAssigned csh@CourseShorthand sheetName@SheetName: Ihnen wurden Korrekturen zu #{sheetName} in #{csh} zugeteilt +MailCorrectionsAssignedIntro courseName@Text termDesc@Text sheetName@SheetName n@Int: #{display n} #{pluralDE n "Abgabe wurde" "Abgaben wurden"} Ihnen zur Korrektur für #{sheetName} im Kurs #{courseName} (#{termDesc}) zugeteilt. + +MailEditNotifications: Benachrichtigungen ein-/ausschalten +MailSubjectSupport: Supportanfrage + +SheetGrading: Bewertung +SheetGradingPoints maxPoints@Points: #{tshow maxPoints} Punkte +SheetGradingPassPoints maxPoints@Points passingPoints@Points: Bestanden ab #{tshow passingPoints} von #{tshow maxPoints} Punkten +SheetGradingPassBinary: Bestanden/Nicht Bestanden +SheetGradingInfo: "Bestanden nach Punkten" zählt sowohl zur maximal erreichbaren Gesamtpunktzahl also auch zur Anzahl der zu bestehenden Blätter. + +SheetGradingCount': Anzahl +SheetGradingPoints': Punkte +SheetGradingPassing': Bestehen +SheetGradingPassPoints': Bestehen nach Punkten +SheetGradingPassBinary': Bestanden/Nicht bestanden + +SheetTypeBonus grading@SheetGrading: Bonus +SheetTypeNormal grading@SheetGrading: Normal +SheetTypeInformational grading@SheetGrading: Keine Wertung +SheetTypeNotGraded: Unbewertet +SheetTypeInfoNotGraded: Blätter ohne Wertung werden nirgends angerechnet, die Bewertung durch den Korrektor dient lediglich zur Information der Teilnehmer. +SheetTypeInfoBonus: Bonus Blätter zählen normal, erhöhen aber nicht die maximal erreichbare Punktzahl bzw. Anzahl zu bestehender Blätter. +SheetGradingBonusIncluded: Erzielte Bonuspunkte wurden hier bereits zu den erreichten normalen Punkten hinzugezählt. +SummaryTitle: Zusammenfassung über +SheetGradingSummaryTitle intgr@Integer: #{display intgr} #{pluralDE intgr "Blatt" "Blätter"} +SubmissionGradingSummaryTitle intgr@Integer: #{display intgr} #{pluralDE intgr "Abgabe" "Abgaben"} + +SheetTypeBonus': Bonus +SheetTypeNormal': Normal +SheetTypeInformational': Keine Wertung +SheetTypeNotGraded': Unbewertet + +SheetGradingMaxPoints: Maximalpunktzahl +SheetGradingPassingPoints: Notwendig zum Bestehen + +SheetGroupArbitrary: Arbiträre Gruppen +SheetGroupRegisteredGroups: Registrierte Gruppen +SheetGroupNoGroups: Keine Gruppenabgabe +SheetGroupMaxGroupsize: Maximale Gruppengröße + +SheetFiles: Übungsblatt-Dateien + +NotificationTriggerSubmissionRatedGraded: Meine Abgabe in einem gewerteten Übungsblatt wurde korrigiert +NotificationTriggerSubmissionRated: Meine Abgabe wurde korrigiert +NotificationTriggerSheetActive: Ich kann ein neues Übungsblatt herunterladen +NotificationTriggerSheetSoonInactive: Ich kann ein Übungsblatt bald nicht mehr abgeben +NotificationTriggerSheetInactive: Abgabefrist eines meiner Übungsblätter ist abgelaufen +NotificationTriggerCorrectionsAssigned: Mir wurden Abgaben zur Korrektur zugeteilt +NotificationTriggerCorrectionsNotDistributed: Abgaben eines meiner Übungsblätter konnten keinem Korrektur zugeteilt werden + +CorrCreate: Abgaben erstellen +UnknownPseudonymWord pseudonymWord@Text: Unbekanntes Pseudonym-Wort "#{pseudonymWord}" +InvalidPseudonym pseudonym@Text: Invalides Pseudonym "#{pseudonym}" +InvalidPseudonymSubmissionIgnored oPseudonyms@Text iPseudonym@Text: Abgabe mit Pseudonymen „#{oPseudonyms}“ wurde ignoriert, da „#{iPseudonym}“ nicht automatisiert zu einem validen Pseudonym korrigiert werden konnte. +PseudonymAutocorrections: Korrekturvorschläge: +UnknownPseudonym pseudonym@Text: Unbekanntes Pseudonym "#{pseudonym}" +CorrectionPseudonyms: Abgaben-Pseudonyme +CorrectionPseudonymsTip: Eine Abgabe pro Zeile, bei Gruppenabgaben mehrere Pseudonyme (komma-separiert) innerhalb einer Zeile. Kleine Schreibfehler werden u.U. automatisch korrigiert. +PseudonymSheet: Übungsblatt +CorrectionPseudonymSheet termDesc@Text csh@CourseShorthand shn@SheetName: #{termDesc} » #{csh} » #{shn} +SheetGroupTooLarge sheetGroupDesc@Text: Abgabegruppe zu groß: #{sheetGroupDesc} +SheetNoRegisteredGroup sheetGroupDesc@Text: "#{sheetGroupDesc}" sind nicht als Gruppe registriert +SheetAmbiguousRegisteredGroup sheetGroupDesc@Text: "#{sheetGroupDesc}" enthält Mitglieder aus verschiedenen registrierten Gruppen +SheetNoGroupSubmission sheetGroupDesc@Text: Gruppenabgabe ist für dieses Blatt nicht vorgesehen (#{sheetGroupDesc}) +SheetDuplicatePseudonym: Folgende Pseudonyme kamen mehrfach vor; alle Vorkommen außer dem Ersten wurden ignoriert: +SheetCreateExisting: Folgende Pseudonyme haben bereits abgegeben: + +CorrGrade: Korrekturen eintragen + +UserAccountDeleted name@Text: Konto für #{name} wurde gelöscht! + +HelpAnswer: Antworten an +HelpUser: Meinen Benutzeraccount +HelpAnonymous: Keine Antwort (Anonym) +HelpEmail: E-Mail +HelpRequest: Supportanfrage / Verbesserungsvorschlag +HelpProblemPage: Problematische Seite +HelpIntroduction: Wenn Ihnen die Benutzung dieser Webseite Schwierigkeiten bereitet oder Sie einen verbesserbaren Umstand entdecken bitten wir Sie uns das zu melden, auch wenn Sie Ihr Problem bereits selbst lösen konnten. Wir passen die Seite ständig an und versuchen sie auch für zukünftige Benutzer so einsichtig wie möglich zu halten. +HelpSent: Ihre Supportanfrage wurde weitergeleitet. + +SystemMessageFrom: Sichtbar ab +SystemMessageTo: Sichtbar bis +SystemMessageAuthenticatedOnly: Nur angemeldet +SystemMessageSeverity: Schwere +SystemMessageId: Id +SystemMessageSummaryContent: Zusammenfassung / Inhalt +SystemMessageSummary: Zusammenfassung +SystemMessageContent: Inhalt +SystemMessageLanguage: Sprache + +SystemMessageDelete: Löschen +SystemMessageActivate: Sichtbar schalten +SystemMessageDeactivate: Unsichtbar schalten +SystemMessageTimestamp: Zeitpunkt + +SystemMessagesDeleted: System-Nachrichten gelöscht: +SystemMessagesActivated: Aktivierungszeitpunkt folgender System-Nachrichten gesetzt: +SystemMessagesDeactivated: Deaktivierungszeitpunkt folgender System-Nachrichten gesetzt: +SystemMessageEmptySelection: Keine System-Nachrichten ausgewählt +SystemMessageAdded sysMsgId@CryptoUUIDSystemMessage: System-Nachricht hinzugefügt: #{toPathPiece sysMsgId} +SystemMessageEdit: Statusmeldung anpassen +SystemMessageEditTranslations: Übersetzungen anpassen +SystemMessageAddTranslation: Übersetzung hinzufügen + +SystemMessageEditSuccess: Statusmeldung angepasst. +SystemMessageAddTranslationSuccess: Übersetzung hinzugefügt. +SystemMessageEditTranslationSuccess: Übersetzung angepasst. +SystemMessageDeleteTranslationSuccess: Übersetzung entfernt. + +MessageError: Fehler +MessageWarning: Warnung +MessageInfo: Information +MessageSuccess: Erfolg + +InvalidLangFormat: Ungültiger Sprach-Code (RFC1766) + +ErrorResponseTitleNotFound: Ressource nicht gefunden +ErrorResponseTitleInternalError internalError@Text: Ein interner Fehler ist aufgetreten +ErrorResponseTitleInvalidArgs invalidArgs@Texts: Anfrage-Nachricht enthielt ungültige Argumente +ErrorResponseTitleNotAuthenticated: Anfrage benötigt Authentifizierung +ErrorResponseTitlePermissionDenied permissionDenied@Text: Mangelnde Authorisierung +ErrorResponseTitleBadMethod requestMethod@Method: HTTP-Methode nicht unterstützt + +UnknownErrorResponse: Ein nicht weiter klassifizierter Fehler ist aufgetreten: +ErrorResponseNotFound: Unter der von Ihrem Browser angefragten URL wurde keine Seite gefunden. +ErrorResponseNotAuthenticated: Um Zugriff auf einige Teile des Systems zu erhalten müssen Sie sich zunächst anmelden. +ErrorResponseBadMethod requestMethodText@Text: Ihr Browser kann auf mehrere verschiedene Arten versuchen mit den vom System angebotenen Ressourcen zu interagieren. Die aktuell versuchte Methode (#{requestMethodText}) wird nicht unterstützt. + +ErrorResponseEncrypted: Um keine sensiblen Daten preiszugeben wurden nähere Details verschlüsselt. Wenn Sie eine Anfrage an den Support schicken fügen Sie bitte die unten aufgeführten verschlüsselten Daten mit an. +ErrMsgCiphertext: Verschlüsselte Fehlermeldung +EncodedSecretBoxCiphertextTooShort: Verschlüsselte Daten zu kurz um valide zu sein +EncodedSecretBoxInvalidBase64 base64Err@String: Verschlüsselte Daten nicht korrekt base64url-kodiert: #{base64Err} +EncodedSecretBoxInvalidPadding: Verschlüsselte Daten sind nicht korrekt padded +EncodedSecretBoxCouldNotDecodeNonce: Konnte secretbox-nonce nicht dekodieren +EncodedSecretBoxCouldNotOpenSecretBox: Konnte libsodium-secretbox nicht öffnen (Verschlüsselte Daten sind nicht authentisch) +EncodedSecretBoxCouldNotDecodePlaintext aesonErr@String: Konnte Klartext nicht JSON-dekodieren: #{aesonErr} +ErrMsgHeading: Fehlermeldung entschlüsseln +ErrorCryptoIdMismatch: Verschlüsselte Id der Abgabe passte nicht zu anderen Daten + +InvalidRoute: Konnte URL nicht interpretieren + +MenuHome: Aktuell +MenuVersion: Impressum +MenuHelp: Hilfe +MenuProfile: Anpassen +MenuLogin: Login +MenuLogout: Logout +MenuCourseList: Kurse +MenuTermShow: Semester +MenuSubmissionDelete: Abgabe löschen +MenuUsers: Benutzer +MenuAdminTest: Admin-Demo +MenuMessageList: Systemnachrichten +MenuAdminErrMsg: Fehlermeldung entschlüsseln +MenuProfileData: Persönliche Daten +MenuTermCreate: Neues Semester anlegen +MenuCourseNew: Neuen Kurs anlegen +MenuTermEdit: Semester editieren +MenuCorrection: Korrektur +MenuCorrections: Korrekturen +MenuCorrectionsOwn: Meine Korrekturen +MenuSubmissions: Abgaben +MenuSheetList: Übungsblätter +MenuSheetNew: Neues Übungsblatt anlegen +MenuSheetCurrent: Aktuelles Übungsblatt +MenuSheetOldUnassigned: Abgaben ohne Korrektor +MenuCourseEdit: Kurs editieren +MenuCourseClone: Als neuen Kurs klonen +MenuCourseDelete: Kurs löschen +MenuSubmissionNew: Abgabe anlegen +MenuSubmissionOwn: Abgabe +MenuCorrectors: Korrektoren +MenuSheetEdit: Übungsblatt editieren +MenuSheetDelete: Übungsblatt löschen +MenuSheetClone: Als neues Übungsblatt klonen +MenuCorrectionsUpload: Korrekturen hochladen +MenuCorrectionsCreate: Abgaben registrieren +MenuCorrectionsGrade: Abgaben bewerten +MenuAuthPreds: Authorisierungseinstellungen + +AuthPredsActive: Aktive Authorisierungsprädikate +AuthPredsActiveChanged: Authorisierungseinstellungen für aktuelle Sitzung gespeichert +AuthTagFree: Seite ist universell zugänglich +AuthTagAdmin: Nutzer ist Administrator +AuthTagNoEscalation: Nutzer-Rechte werden nicht erweitert +AuthTagDeprecated: Seite ist nicht überholt +AuthTagDevelopment: Seite ist nicht in Entwicklung +AuthTagLecturer: Nutzer ist Dozent +AuthTagCorrector: Nutzer ist Korrektor +AuthTagTime: Zeitliche Einschränkungen sind erfüllt +AuthTagRegistered: Nutzer ist Kursteilnehmer +AuthTagParticipant: Nutzer ist mit Kurs assoziiert +AuthTagCapacity: Kapazität ist ausreichend +AuthTagEmpty: Kurs hat keine Teilnehmer +AuthTagMaterials: Kursmaterialien sind freigegeben +AuthTagOwner: Nutzer ist Besitzer +AuthTagRated: Korrektur ist bewertet +AuthTagUserSubmissions: Abgaben erfolgen durch Kursteilnehmer +AuthTagCorrectorSubmissions: Abgaben erfolgen durch Korrektoren +AuthTagAuthentication: Authentifizierung erfüllt Anforderungen +AuthTagRead: Zugriff ist nur lesend +AuthTagWrite: Zugriff ist i.A. schreibend + +DeleteCopyStringIfSure n@Int: Wenn Sie sich sicher sind, dass Sie #{pluralDE n "das obige Objekt" "obige Objekte"} unwiderbringlich löschen möchten, schreiben Sie bitte zunächst den angezeigten Text ab. +DeleteConfirmation: Bestätigung +DeleteConfirmationWrong: Bestätigung muss genau dem angezeigten Text entsprechen. + +DBTIRowsMissing n@Int: #{pluralDE n "Eine Zeile ist" "Einige Zeile sind"} aus der Datenbank verschwunden, seit das Formular für Sie generiert wurde diff --git a/models b/models deleted file mode 100644 index 194a9c063..000000000 --- a/models +++ /dev/null @@ -1,223 +0,0 @@ -User json - ident (CI Text) - authentication AuthenticationMode - matrikelnummer Text Maybe - email (CI Text) - displayName Text - surname Text -- always use: nameWidget displayName surname - maxFavourites Int default=12 - theme Theme default='Default' - dateTimeFormat DateTimeFormat "default='%a %d %b %Y %R'" - dateFormat DateTimeFormat "default='%d.%m.%Y'" - timeFormat DateTimeFormat "default='%R'" - downloadFiles Bool default=false - UniqueAuthentication ident - UniqueEmail email - deriving Show -UserAdmin - user UserId - school SchoolId - UniqueUserAdmin user school -UserLecturer - user UserId - school SchoolId - UniqueSchoolLecturer user school -StudyFeatures - user UserId - degree StudyDegreeId - field StudyTermsId - type StudyFieldType - semester Int - -- UniqueUserSubject user degree field -- There exists a counterexample -StudyDegree - key Int - shorthand Text Maybe - name Text Maybe - Primary key -StudyTerms - key Int - shorthand Text Maybe - name Text Maybe - Primary key -Term json - name TermIdentifier -- unTermKey :: TermId -> TermIdentifier - start Day -- TermKey :: TermIdentifier -> TermId - end Day - holidays [Day] - lectureStart Day - lectureEnd Day - active Bool - Primary name -- newtype Key Term = TermKey { unTermKey :: TermIdentifier } - deriving Show -- type TermId = Key Term -School json - name (CI Text) - shorthand (CI Text) - UniqueSchool name - UniqueSchoolShorthand shorthand -- required for Normalisation of CI Text - Primary shorthand -- newtype Key School = SchoolKey { unSchoolKey :: SchoolShorthand } - deriving Eq -DegreeCourse json - course CourseId - degree StudyDegreeId - terms StudyTermsId - UniqueDegreeCourse course degree terms -Course - name (CI Text) - description Html Maybe - linkExternal Text Maybe - shorthand (CI Text) - term TermId - school SchoolId - capacity Int64 Maybe - -- canRegisterNow = maybe False (<= currentTime) registerFrom && maybe True (>= currentTime) registerTo - registerFrom UTCTime Maybe - registerTo UTCTime Maybe - deregisterUntil UTCTime Maybe - registerSecret Text Maybe -- Falls ein Passwort erforderlich ist - materialFree Bool - TermSchoolCourseShort term school shorthand - TermSchoolCourseName term school name -CourseEdit - user UserId - time UTCTime - course CourseId -CourseFavourite - user UserId - time UTCTime - course CourseId - UniqueCourseFavourite user course - deriving Show -Lecturer - user UserId - course CourseId - UniqueLecturer user course -CourseParticipant - course CourseId - user UserId - registration UTCTime - UniqueParticipant user course -Sheet - course CourseId - name (CI Text) - description Html Maybe - type SheetType - grouping SheetGroup - markingText Html Maybe - visibleFrom UTCTime Maybe - activeFrom UTCTime - activeTo UTCTime - hintFrom UTCTime Maybe - solutionFrom UTCTime Maybe - uploadMode UploadMode - CourseSheet course name -SheetEdit - user UserId - time UTCTime - sheet SheetId -SheetCorrector - user UserId - sheet SheetId - load Load - state CorrectorState default='CorrectorNormal' - UniqueSheetCorrector user sheet - deriving Show Eq Ord -SheetFile - sheet SheetId - file FileId - type SheetFileType - UniqueSheetFile file sheet type -File - title FilePath - content ByteString Maybe -- Nothing iff this is a directory - modified UTCTime - deriving Show Eq -Submission - sheet SheetId - ratingPoints Points Maybe -- "Just" does not mean done - ratingComment Text Maybe -- "Just" does not mean done - ratingBy UserId Maybe -- assigned corrector - ratingAssigned UTCTime Maybe -- time assigned corrector - ratingTime UTCTime Maybe -- "Just" here indicates done! - deriving Show -SubmissionEdit - user UserId - time UTCTime - submission SubmissionId -SubmissionFile - submission SubmissionId - file FileId - isUpdate Bool -- is this the file updated by a corrector (original will always be retained) - isDeletion Bool -- only set if isUpdate is also set, but file was deleted by corrector - UniqueSubmissionFile file submission isUpdate - deriving Show -SubmissionUser -- Actual submission participant - user UserId - submission SubmissionId - UniqueSubmissionUser user submission -SubmissionGroup - course CourseId - name Text Maybe -SubmissionGroupEdit - user UserId - time UTCTime - submissionGroup SubmissionGroupId -SubmissionGroupUser -- Registered submission groups, independent of actual SubmissionUser - submissionGroup SubmissionGroupId - user UserId - UniqueSubmissionGroupUser submissionGroup user -Tutorial json - name Text - tutor UserId - course CourseId -TutorialUser - user UserId - tutorial TutorialId - UniqueTutorialUser user tutorial -Booking - term TermId - begin UTCTime - end UTCTime - weekly Bool - exceptions [Day] -- only if weekly, begin in exception - bookedFor RoomForId - room RoomId -BookingEdit - user UserId - time UTCTime - boooking BookingId -Room - name Text - capacity Int Maybe - building Text Maybe --- BookingRoom --- subject RoomForId --- room RoomId --- booking BookingId --- UniqueRoomCourse subject room booking -+RoomFor - course CourseId - tutorial TutorialId - exam ExamId --- data RoomFor = RoomForCourseSum CourseId | RoomForTutorialSum TutorialId ... --- EXAMS ARE TODO: -Exam - course CourseId - name Text - description Text - begin UTCTime - end UTCTime - registrationBegin UTCTime - registrationEnd UTCTime - deregistrationEnd UTCTime - ratingVisible Bool - statisticsVisible Bool ---ExamEdit --- user UserId --- time UTCTime --- exam ExamId ---ExamUser --- user UserId --- examId ExamId --- -- CONTINUE HERE: Include rating in this table or separately? --- UniqueExamUser user examId --- By default this file is used in Model.hs (which is imported by Foundation.hs) diff --git a/models/config b/models/config new file mode 100644 index 000000000..33bcaf8d6 --- /dev/null +++ b/models/config @@ -0,0 +1,4 @@ +ClusterConfig + setting ClusterSettingsKey + value Value + Primary setting \ No newline at end of file diff --git a/models/courses b/models/courses new file mode 100644 index 000000000..96bba0195 --- /dev/null +++ b/models/courses @@ -0,0 +1,50 @@ +DegreeCourse json + course CourseId + degree StudyDegreeId + terms StudyTermsId + UniqueDegreeCourse course degree terms +Course + name (CI Text) + description Html Maybe + linkExternal Text Maybe + shorthand (CI Text) + term TermId + school SchoolId + capacity Int64 Maybe + -- canRegisterNow = maybe False (<= currentTime) registerFrom && maybe True (>= currentTime) registerTo + registerFrom UTCTime Maybe + registerTo UTCTime Maybe + deregisterUntil UTCTime Maybe + registerSecret Text Maybe -- Falls ein Passwort erforderlich ist + materialFree Bool + TermSchoolCourseShort term school shorthand + TermSchoolCourseName term school name + deriving Generic +CourseEdit + user UserId + time UTCTime + course CourseId +CourseFavourite + user UserId + time UTCTime + course CourseId + UniqueCourseFavourite user course + deriving Show +Lecturer + user UserId + course CourseId + UniqueLecturer user course +CourseParticipant + course CourseId + user UserId + registration UTCTime + UniqueParticipant user course +CourseUserNote + course CourseId + user UserId + note Text + UniqueCourseUserNotes user course +CourseUserNoteEdit + user UserId + time UTCTime + note CourseUserNoteId diff --git a/models/exams b/models/exams new file mode 100644 index 000000000..e356e4221 --- /dev/null +++ b/models/exams @@ -0,0 +1,22 @@ +-- EXAMS ARE TODO: +Exam + course CourseId + name Text + description Text + begin UTCTime + end UTCTime + registrationBegin UTCTime + registrationEnd UTCTime + deregistrationEnd UTCTime + ratingVisible Bool + statisticsVisible Bool +--ExamEdit +-- user UserId +-- time UTCTime +-- exam ExamId +--ExamUser +-- user UserId +-- examId ExamId +-- -- CONTINUE HERE: Include rating in this table or separately? +-- UniqueExamUser user examId +-- By default this file is used in Model.hs (which is imported by Foundation.hs) \ No newline at end of file diff --git a/models/files b/models/files new file mode 100644 index 000000000..62a5ffe72 --- /dev/null +++ b/models/files @@ -0,0 +1,5 @@ +File + title FilePath + content ByteString Maybe -- Nothing iff this is a directory + modified UTCTime + deriving Show Eq Generic diff --git a/models/jobs b/models/jobs new file mode 100644 index 000000000..15f7bb7dc --- /dev/null +++ b/models/jobs @@ -0,0 +1,12 @@ +QueuedJob + content Value + creationInstance InstanceId + creationTime UTCTime + lockInstance InstanceId Maybe + lockTime UTCTime Maybe + deriving Eq Read Show Generic Typeable +CronLastExec + job Value + time UTCTime + instance InstanceId + UniqueCronLastExec job diff --git a/models/rooms b/models/rooms new file mode 100644 index 000000000..7b62d41f5 --- /dev/null +++ b/models/rooms @@ -0,0 +1,26 @@ +Booking + term TermId + begin UTCTime + end UTCTime + weekly Bool + exceptions [Day] -- only if weekly, begin in exception + bookedFor RoomForId + room RoomId +BookingEdit + user UserId + time UTCTime + boooking BookingId +Room + name Text + capacity Int Maybe + building Text Maybe +-- BookingRoom +-- subject RoomForId +-- room RoomId +-- booking BookingId +-- UniqueRoomCourse subject room booking ++RoomFor + course CourseId + tutorial TutorialId + exam ExamId +-- data RoomFor = RoomForCourseSum CourseId | RoomForTutorialSum TutorialId ... diff --git a/models/schools b/models/schools new file mode 100644 index 000000000..625235f2f --- /dev/null +++ b/models/schools @@ -0,0 +1,7 @@ +School json + name (CI Text) + shorthand (CI Text) + UniqueSchool name + UniqueSchoolShorthand shorthand -- required for Normalisation of CI Text + Primary shorthand -- newtype Key School = SchoolKey { unSchoolKey :: SchoolShorthand } + deriving Eq Show Generic diff --git a/models/sheets b/models/sheets new file mode 100644 index 000000000..8fd75eae1 --- /dev/null +++ b/models/sheets @@ -0,0 +1,39 @@ +Sheet + course CourseId + name (CI Text) + description Html Maybe + type SheetType + grouping SheetGroup + markingText Html Maybe + visibleFrom UTCTime Maybe + activeFrom UTCTime + activeTo UTCTime + hintFrom UTCTime Maybe + solutionFrom UTCTime Maybe + uploadMode UploadMode + submissionMode SheetSubmissionMode default='UserSubmissions' + autoDistribute Bool default=false + CourseSheet course name + deriving Generic +SheetEdit + user UserId + time UTCTime + sheet SheetId +SheetPseudonym + sheet SheetId + pseudonym Pseudonym + user UserId + UniqueSheetPseudonym sheet pseudonym + UniqueSheetPseudonymUser sheet user +SheetCorrector + user UserId + sheet SheetId + load Load + state CorrectorState default='CorrectorNormal' + UniqueSheetCorrector user sheet + deriving Show Eq Ord +SheetFile + sheet SheetId + file FileId + type SheetFileType + UniqueSheetFile file sheet type diff --git a/models/submissions b/models/submissions new file mode 100644 index 000000000..ff998b845 --- /dev/null +++ b/models/submissions @@ -0,0 +1,34 @@ +Submission + sheet SheetId + ratingPoints Points Maybe -- "Just" does not mean done + ratingComment Text Maybe -- "Just" does not mean done + ratingBy UserId Maybe -- assigned corrector + ratingAssigned UTCTime Maybe -- time assigned corrector + ratingTime UTCTime Maybe -- "Just" here indicates done! + deriving Show Generic +SubmissionEdit + user UserId + time UTCTime + submission SubmissionId +SubmissionFile + submission SubmissionId + file FileId + isUpdate Bool -- is this the file updated by a corrector (original will always be retained) + isDeletion Bool -- only set if isUpdate is also set, but file was deleted by corrector + UniqueSubmissionFile file submission isUpdate + deriving Show +SubmissionUser -- Actual submission participant + user UserId + submission SubmissionId + UniqueSubmissionUser user submission +SubmissionGroup + course CourseId + name Text Maybe +SubmissionGroupEdit + user UserId + time UTCTime + submissionGroup SubmissionGroupId +SubmissionGroupUser -- Registered submission groups, independent of actual SubmissionUser + submissionGroup SubmissionGroupId + user UserId + UniqueSubmissionGroupUser submissionGroup user diff --git a/models/system-messages b/models/system-messages new file mode 100644 index 000000000..0547718ae --- /dev/null +++ b/models/system-messages @@ -0,0 +1,14 @@ +SystemMessage + from UTCTime Maybe + to UTCTime Maybe + authenticatedOnly Bool + severity MessageClass + defaultLanguage Lang + content Html + summary Html Maybe +SystemMessageTranslation + message SystemMessageId + language Lang + content Html + summary Html Maybe + UniqueSystemMessageTranslation message language diff --git a/models/terms b/models/terms new file mode 100644 index 000000000..698a6a6d1 --- /dev/null +++ b/models/terms @@ -0,0 +1,10 @@ +Term json + name TermIdentifier -- unTermKey :: TermId -> TermIdentifier + start Day -- TermKey :: TermIdentifier -> TermId + end Day + holidays [Day] + lectureStart Day + lectureEnd Day + active Bool + Primary name -- newtype Key Term = TermKey { unTermKey :: TermIdentifier } + deriving Show Eq Generic -- type TermId = Key Term diff --git a/models/tutorials b/models/tutorials new file mode 100644 index 000000000..51e20b195 --- /dev/null +++ b/models/tutorials @@ -0,0 +1,8 @@ +Tutorial json + name Text + tutor UserId + course CourseId +TutorialUser + user UserId + tutorial TutorialId + UniqueTutorialUser user tutorial diff --git a/models/users b/models/users new file mode 100644 index 000000000..5ac4a6a3c --- /dev/null +++ b/models/users @@ -0,0 +1,43 @@ +User json + ident (CI Text) + authentication AuthenticationMode + matrikelnummer Text Maybe + email (CI Text) + displayName Text + surname Text -- always use: nameWidget displayName surname + maxFavourites Int default=12 + theme Theme default='Default' + dateTimeFormat DateTimeFormat "default='%a %d %b %Y %R'" + dateFormat DateTimeFormat "default='%d.%m.%Y'" + timeFormat DateTimeFormat "default='%R'" + downloadFiles Bool default=false + mailLanguages MailLanguages default='[]' + notificationSettings NotificationSettings + UniqueAuthentication ident + UniqueEmail email + deriving Show Eq Generic +UserAdmin + user UserId + school SchoolId + UniqueUserAdmin user school +UserLecturer + user UserId + school SchoolId + UniqueSchoolLecturer user school +StudyFeatures + user UserId + degree StudyDegreeId + field StudyTermsId + type StudyFieldType + semester Int + -- UniqueUserSubject user degree field -- There exists a counterexample +StudyDegree + key Int + shorthand Text Maybe + name Text Maybe + Primary key +StudyTerms + key Int + shorthand Text Maybe + name Text Maybe + Primary key diff --git a/package.yaml b/package.yaml index 613489a82..46af6eab8 100644 --- a/package.yaml +++ b/package.yaml @@ -2,115 +2,195 @@ name: uniworx version: "0.0.0" dependencies: + # Due to a bug in GHC 8.0.1, we block its usage + # See: https://ghc.haskell.org/trac/ghc/ticket/12130 + - base >=4.8.2.0 && <4.9 || >=4.9.1.0 && <5 + # version 1.0 had a bug in reexporting Handler, causing trouble + - classy-prelude-yesod >=0.10.2 && <1.0 || >=1.1 + - foreign-store + - yesod >=1.4.3 && <1.5 + - yesod-core >=1.4.30 && <1.5 + - yesod-auth >=1.4.0 && <1.5 + - yesod-static >=1.4.0.3 && <1.6 + - yesod-form >=1.4.0 && <1.5 + - classy-prelude >=0.10.2 + - classy-prelude-conduit >=0.10.2 + - bytestring >=0.9 && <0.11 + - text >=0.11 && <2.0 + - persistent >=2.7.2 && <2.8 + - persistent-postgresql >=2.1.1 && <2.8 + - persistent-template >=2.0 && <2.8 + - template-haskell + - shakespeare >=2.0 && <2.1 + - hjsmin >=0.1 && <0.3 + - monad-control >=0.3 && <1.1 + - wai-extra >=3.0 && <3.1 + - yaml >=0.8 && <0.9 + - http-conduit >=2.1 && <2.3 + - directory >=1.1 && <1.4 + - warp >=3.0 && <3.3 + - data-default + - aeson >=0.6 && <1.3 + - conduit >=1.0 && <2.0 + - monad-logger >=0.3 && <0.4 + - fast-logger >=2.2 && <2.5 + - wai-logger >=2.2 && <2.4 + - file-embed + - safe + - unordered-containers + - containers + - vector + - time + - case-insensitive + - wai + - cryptonite + - cryptonite-conduit + - saltine + - base64-bytestring + - memory + - http-api-data + - profunctors + - colonnade >=1.1.1 + - yesod-colonnade >=1.1.0 + - blaze-markup + - zip-stream + - filepath + - transformers + - wl-pprint-text + - uuid-types + - path-pieces + - uuid-crypto + - filepath-crypto + - cryptoids-types + - cryptoids + - cryptoids-class + - binary + - cereal + - mtl + - sandi + - esqueleto + - mime-types + - generic-deriving + - blaze-html + - conduit-resumablesink >=0.2 + - parsec + - uuid + - exceptions + - stm + - stm-chans + - stm-conduit + - lens + - MonadRandom + - email-validate + - scientific + - tz + - system-locale + - th-lift-instances + - gitrev + - Glob + - ldap-client + - connection + - universe + - universe-base + - random + - random-shuffle + - th-abstraction + - HaskellNet + - HaskellNet-SSL + - network + - resource-pool + - mime-mail + - hashable + - aeson-pretty + - resourcet + - postgresql-simple + - word24 + - mmorph + - clientsession + - monad-memo + - xss-sanitize + - text-metrics + - pkcs7 + - memcached-binary + - directory-tree + - lifted-base -# Due to a bug in GHC 8.0.1, we block its usage -# See: https://ghc.haskell.org/trac/ghc/ticket/12130 -- base >=4.8.2.0 && <4.9 || >=4.9.1.0 && <5 +other-extensions: + - GeneralizedNewtypeDeriving + - IncoherentInstances + - OverloadedLists + - UndecidableInstances -# version 1.0 had a bug in reexporting Handler, causing trouble -- classy-prelude-yesod >=0.10.2 && <1.0 || >=1.1 +default-extensions: + - OverloadedStrings + - PartialTypeSignatures + - ScopedTypeVariables + - TemplateHaskell + - QuasiQuotes + - CPP + - TypeSynonymInstances + - KindSignatures + - ConstraintKinds + - ViewPatterns + - TypeOperators + - TupleSections + - TypeFamilies + - GADTs + - StandaloneDeriving + - RecordWildCards + - RankNTypes + - PatternGuards + - PatternSynonyms + - ParallelListComp + - NumDecimals + - MultiWayIf + - NamedFieldPuns + - NoImplicitPrelude + - LambdaCase + - MultiParamTypeClasses + - FlexibleContexts + - FlexibleInstances + - FunctionalDependencies + - EmptyDataDecls + - ExistentialQuantification + - DefaultSignatures + - DeriveDataTypeable + - DeriveGeneric + - DeriveLift + - DeriveFunctor + - DerivingStrategies + - DataKinds + - BinaryLiterals + - PolyKinds + - PackageImports + - TypeApplications + - RecursiveDo -- foreign-store -- yesod >=1.4.3 && <1.5 -- yesod-core >=1.4.30 && <1.5 -- yesod-auth >=1.4.0 && <1.5 -- yesod-static >=1.4.0.3 && <1.6 -- yesod-form >=1.4.0 && <1.5 -- classy-prelude >=0.10.2 -- classy-prelude-conduit >=0.10.2 -- bytestring >=0.9 && <0.11 -- text >=0.11 && <2.0 -- persistent >=2.7.2 && <2.8 -- persistent-postgresql >=2.1.1 && <2.8 -- persistent-template >=2.0 && <2.8 -- template-haskell -- shakespeare >=2.0 && <2.1 -- hjsmin >=0.1 && <0.3 -- monad-control >=0.3 && <1.1 -- wai-extra >=3.0 && <3.1 -- yaml >=0.8 && <0.9 -- http-conduit >=2.1 && <2.3 -- directory >=1.1 && <1.4 -- warp >=3.0 && <3.3 -- data-default -- aeson >=0.6 && <1.3 -- conduit >=1.0 && <2.0 -- monad-logger >=0.3 && <0.4 -- fast-logger >=2.2 && <2.5 -- wai-logger >=2.2 && <2.4 -- file-embed -- safe -- unordered-containers -- containers -- vector -- time -- case-insensitive -- wai -- cryptonite -- cryptonite-conduit -- base64-bytestring -- memory -- http-api-data -- profunctors -- colonnade >=1.1.1 -- yesod-colonnade >=1.1.0 -- blaze-markup -- zip-stream -- filepath -- transformers -- wl-pprint-text -- uuid-types -- path-pieces -- uuid-crypto -- filepath-crypto -- cryptoids-types -- cryptoids -- cryptoids-class -- binary -- mtl -- sandi -- esqueleto -- mime-types -- generic-deriving -- blaze-html -- conduit-resumablesink >=0.2 -- parsec -- uuid -- exceptions -- lens -- MonadRandom -- email-validate -- scientific -- tz -- system-locale -- th-lift-instances -- gitrev -- Glob -- ldap-client -- connection -- universe -- universe-base -- random-shuffle -- th-abstraction +ghc-options: + - -Wall + - -fno-warn-type-defaults + - -fno-warn-partial-type-signatures + +when: + - condition: flag(pedantic) + ghc-options: + - -Werror + - -fwarn-tabs # The library contains all of our application code. The executable # defined below is just a thin wrapper. library: source-dirs: src when: - - condition: (flag(dev)) || (flag(library-only)) - then: - ghc-options: - - -Wall - - -fwarn-tabs - - -O0 - - -ddump-splices - cpp-options: -DDEVELOPMENT - else: - ghc-options: - - -Wall - - -fwarn-tabs - - -O2 + - condition: (flag(dev)) || (flag(library-only)) + then: + ghc-options: + - -O0 + - -ddump-splices + cpp-options: -DDEVELOPMENT + else: + ghc-options: + - -O2 # Runnable executable for our application executables: @@ -118,28 +198,52 @@ executables: main: main.hs source-dirs: app ghc-options: - - -threaded - - -rtsopts - - -with-rtsopts=-N + - -threaded + - -rtsopts + - -with-rtsopts=-N dependencies: - - uniworx + - uniworx when: - - condition: flag(library-only) - buildable: false + - condition: flag(library-only) + buildable: false + uniworxdb: + main: Database.hs + ghc-options: + - -main-is Database + source-dirs: test + dependencies: + - uniworx + other-modules: [] # Test suite tests: - test: - main: Spec.hs + yesod: + main: Main.hs source-dirs: test - ghc-options: -Wall dependencies: - - uniworx - - hspec >=2.0.0 - - QuickCheck - - yesod-test - - conduit-extra - - quickcheck-instances + - uniworx + - hspec >=2.0.0 + - QuickCheck + - yesod-test + - conduit-extra + - quickcheck-classes + - quickcheck-instances + - generic-arbitrary + - http-types + ghc-options: + - -fno-warn-orphans + - -threaded + - -rtsopts + - -with-rtsopts=-N + hlint: + main: Hlint.hs + other-modules: [] + source-dirs: hlint + dependencies: + - hlint-test + when: + - condition: "!flag(pedantic)" + buildable: false # Define flags used by "yesod devel" to make compilation faster flags: @@ -151,3 +255,7 @@ flags: description: Turn on development settings, like auto-reload templates. manual: false default: false + pedantic: + description: Be very pedantic about warnings and errors + manual: false + default: true diff --git a/routes b/routes index 6116665dc..1c1535769 100644 --- a/routes +++ b/routes @@ -10,36 +10,42 @@ -- Admins always have access to entities within their assigned schools. -- -- Access Tags: --- !free -- free for all --- !lecturer -- lecturer for this course (or the school, if route is not connected to a course) --- !corrector -- corrector for this sheet (or the submission, if route is connected to a submission, or the course, if route is not connected to a sheet, or any course, if route is not connected to a course) --- !registered -- participant for this course (no effect outside of courses) --- !owner -- part of the group of owners of this submission --- !capacity -- course this route is associated with has at least one unit of participant capacity +-- !free -- free for all +-- !lecturer -- lecturer for this course (or the school, if route is not connected to a course) +-- !corrector -- corrector for this sheet (or the submission, if route is connected to a submission, or the course, if route is not connected to a sheet, or any course, if route is not connected to a course) +-- !registered -- participant for this course (no effect outside of courses) +-- !participant -- connected with a given course (not necessarily registered), i.e. has a submission, is a corrector, etc. (no effect outside of courses) +-- !owner -- part of the group of owners of this submission +-- !capacity -- course this route is associated with has at least one unit of participant capacity +-- !empty -- course this route is associated with has no participants whatsoever -- --- !materials -- only if course allows all materials to be free (no meaning outside of courses) --- !time -- access depends on time somehow --- !isRead -- only if it is read-only access (i.e. GET but not POST) --- !isWrite -- only if it is write access (i.e. POST only) why needed??? --- --- !deprecated -- like free, but logs and gives a warning; entirely disabled in production +-- !materials -- only if course allows all materials to be free (no meaning outside of courses) +-- !time -- access depends on time somehow +-- !read -- only if it is read-only access (i.e. GET but not POST) +-- !write -- only if it is write access (i.e. POST only, included for completeness) -- +-- !deprecated -- like free, but logs and gives a warning; entirely disabled in production +-- !development -- like free, but only for development builds -/static StaticR Static appStatic !free +/static StaticR EmbeddedStatic appStatic !free /auth AuthR Auth getAuth !free /favicon.ico FaviconR GET !free /robots.txt RobotsR GET !free -/ HomeR GET !free -/users UsersR GET -- no tags, i.e. admins only -/admin/test AdminTestR GET POST -/admin/user/#CryptoUUIDUser AdminUserR GET -/admin/user/#CryptoUUIDUser/hijack AdminHijackUserR POST -/info VersionR GET !free +/ HomeR GET !free +/users UsersR GET -- no tags, i.e. admins only +/users/#CryptoUUIDUser AdminUserR GET !development +/users/#CryptoUUIDUser/hijack AdminHijackUserR POST !adminANDno-escalation +/admin/test AdminTestR GET POST +/admin/errMsg AdminErrMsgR GET POST +/info VersionR GET !free +/help HelpR GET POST !free -/profile ProfileR GET POST !free !free -/profile/data ProfileDataR GET POST !free !free +/profile ProfileR GET POST !free +/profile/data ProfileDataR GET POST !free + +/authpreds AuthPredsR GET POST !free /term TermShowR GET !free /term/current TermCurrentR GET !free @@ -48,43 +54,54 @@ !/term/#TermId TermCourseListR GET !free !/term/#TermId/#SchoolId TermSchoolCourseListR GET !free -/school SchoolListR GET -/school/#SchoolId SchoolShowR GET +/school SchoolListR GET !development +/school/#SchoolId SchoolShowR GET !development -- For Pattern Synonyms see Foundation /course/ CourseListR GET !free !/course/new CourseNewR GET POST !lecturer -!/course/new/#{Maybe TermId}/#{Maybe SchoolId}/#{Maybe CourseShorthand} CourseNewTemplateR GET !lecturer /course/#TermId/#SchoolId/#CourseShorthand CourseR !lecturer: / CShowR GET !free /register CRegisterR POST !timeANDcapacity /edit CEditR GET POST /delete CDeleteR GET POST !lecturerANDempty /users CUsersR GET - /user/#CryptoUUIDUser CUserR GET + /users/#CryptoUUIDUser CUserR GET !lecturerANDparticipant /correctors CHiWisR GET + /notes CNotesR GET POST !corrector /subs CCorrectionsR GET POST - /ex SheetListR GET !registered !materials - !/ex/new SheetNewR GET POST + /ex SheetListR GET !registered !materials !corrector + /ex/new SheetNewR GET POST + /ex/current SheetCurrentR GET !registered !materials !corrector + /ex/unassigned SheetOldUnassigned GET /ex/#SheetName SheetR: - / SShowR GET !timeANDregistered !timeANDmaterials !corrector + /show SShowR GET !timeANDregistered !timeANDmaterials !corrector /edit SEditR GET POST /delete SDelR GET POST /subs SSubsR GET POST -- for lecturer only - /subs/new SubmissionNewR GET POST !timeANDregistered - /subs/own SubmissionOwnR GET !free -- just redirect - /sub/#CryptoFileNameSubmission SubmissionR !correctorANDisRead: - / SubShowR GET POST !ownerANDtime !ownerANDisRead - /archive/#{ZIPArchiveName SubmissionFileType} SubArchiveR GET !owner - /correction CorrectionR GET POST !corrector !ownerANDisReadANDrated - !/#SubmissionFileType/*FilePath SubDownloadR GET !owner + !/subs/new SubmissionNewR GET POST !timeANDregisteredANDuser-submissions + !/subs/own SubmissionOwnR GET !free -- just redirect + /subs/#CryptoFileNameSubmission SubmissionR: + / SubShowR GET POST !ownerANDtime !ownerANDread !correctorANDread + /archive/#{ZIPArchiveName SubmissionFileType} SubArchiveR GET !owner !corrector + /delete SubDelR GET POST !ownerANDtime + /assign SAssignR GET POST !lecturerANDtime + /correction CorrectionR GET POST !corrector !ownerANDreadANDrated + !/#SubmissionFileType/*FilePath SubDownloadR GET !owner !corrector /correctors SCorrR GET POST + /pseudonym SPseudonymR GET POST !registeredANDcorrector-submissions !/#SheetFileType/*FilePath SFileR GET !timeANDregistered !timeANDmaterials !corrector -/corrections CorrectionsR GET POST !corrector !lecturer -/corrections/upload CorrectionsUploadR GET POST !corrector !lecturer +/subs CorrectionsR GET POST !corrector !lecturer +/subs/upload CorrectionsUploadR GET POST !corrector !lecturer +/subs/create CorrectionsCreateR GET POST !corrector !lecturer +/subs/grade CorrectionsGradeR GET POST !corrector !lecturer + + +/msgs MessageListR GET POST +/msg/#{CryptoUUIDSystemMessage} MessageR GET POST !timeANDreadANDauthentication !/#UUID CryptoUUIDDispatchR GET !free -- just redirect diff --git a/shell.nix b/shell.nix index d305354a1..931e7ade0 100644 --- a/shell.nix +++ b/shell.nix @@ -22,7 +22,7 @@ let ''; override = oldAttrs: { - nativeBuildInputs = oldAttrs.nativeBuildInputs ++ (with pkgs; [ postgresql cabal-install ]) ++ (with haskellPackages; [ stack yesod-bin ]); + nativeBuildInputs = oldAttrs.nativeBuildInputs ++ (with pkgs; [ postgresql ]) ++ (with haskellPackages; [ stack yesod-bin hlint cabal-install ]); shellHook = '' export PROMPT_INFO="${oldAttrs.name}" diff --git a/src/Application.hs b/src/Application.hs index 9c4cb5a54..1dd037aba 100644 --- a/src/Application.hs +++ b/src/Application.hs @@ -1,11 +1,5 @@ -{-# LANGUAGE NoImplicitPrelude #-} -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE TemplateHaskell #-} -{-# LANGUAGE MultiParamTypeClasses #-} -{-# LANGUAGE TypeFamilies #-} -{-# LANGUAGE ViewPatterns #-} -{-# LANGUAGE RecordWildCards #-} {-# OPTIONS_GHC -fno-warn-orphans #-} + module Application ( getApplicationDev, getAppDevSettings , appMain @@ -13,6 +7,7 @@ module Application , makeFoundation , makeLogWare -- * for DevelMain + , foundationStoreNum , getApplicationRepl , shutdownApp -- * for GHCI @@ -21,7 +16,7 @@ module Application , addPWEntry ) where -import Control.Monad.Logger (liftLoc, runLoggingT) +import Control.Monad.Logger (liftLoc, LoggingT(..), MonadLoggerIO(..)) import Database.Persist.Postgresql (createPostgresqlPool, pgConnStr, pgPoolSize, runSqlPool) import Import @@ -35,15 +30,48 @@ import Network.Wai.Middleware.RequestLogger (Destination (Logger), IPAddrSource (..), OutputFormat (..), destination, mkRequestLogger, outputFormat) -import System.Log.FastLogger (defaultBufSize, newStdoutLoggerSet, - toLogStr) +import System.Log.FastLogger ( defaultBufSize, newStderrLoggerSet, newStdoutLoggerSet, newFileLoggerSet + , toLogStr, rmLoggerSet + ) + +import qualified Data.Map.Strict as Map + +import Foreign.Store + +import qualified Data.UUID as UUID +import qualified Data.UUID.V4 as UUID + +import System.Directory +import System.FilePath + +import Jobs import qualified Data.Text.Encoding as Text import Yesod.Auth.Util.PasswordStore -import qualified Data.ByteString.Char8 as BS -import qualified Data.Yaml as Yaml +import qualified Data.ByteString.Lazy as LBS +import Network.HaskellNet.SSL hiding (Settings) +import Network.HaskellNet.SMTP.SSL as SMTP hiding (Settings) +import Data.Pool + +import Control.Monad.Trans.Resource + +import System.Log.FastLogger.Date +import qualified Yesod.Core.Types as Yesod (Logger(..)) + +import qualified Data.HashMap.Strict as HashMap + +import Control.Lens + +import Data.Proxy + +import qualified Data.Aeson as Aeson + +import System.Exit (exitFailure) + +import qualified Database.Memcached.Binary.IO as Memcached + -- Import all relevant handler modules here. -- (HPack takes care to add new modules to our cabal file nowadays.) import Handler.Common @@ -58,6 +86,7 @@ import Handler.Sheet import Handler.Submission import Handler.Corrections import Handler.CryptoIDDispatch +import Handler.SystemMessage -- This line actually creates our YesodDispatch instance. It is the second half @@ -69,100 +98,229 @@ mkYesodDispatch "UniWorX" resourcesUniWorX -- performs initialization and returns a foundation datatype value. This is also -- the place to put your migrate statements to have automatic database -- migrations handled by Yesod. -makeFoundation :: AppSettings -> IO UniWorX -makeFoundation appSettings@(AppSettings{..}) = do +makeFoundation :: (MonadResource m, MonadBaseControl IO m) => AppSettings -> m UniWorX +makeFoundation appSettings@AppSettings{..} = do -- Some basic initializations: HTTP connection manager, logger, and static -- subsite. appHttpManager <- newManager - appLogger <- newStdoutLoggerSet defaultBufSize >>= makeYesodLogger - appStatic <- - (if appMutableStatic then staticDevel else static) - appStaticDir + appLogSettings <- liftIO $ newTVarIO appInitialLogSettings - appCryptoIDKey <- readKeyFile appCryptoIDKeyFile + let + mkLogger LogSettings{..} = do + tgetter <- newTimeCache "%Y-%m-%d %T %z" + loggerSet <- case logDestination of + LogDestStderr -> newStderrLoggerSet defaultBufSize + LogDestStdout -> newStdoutLoggerSet defaultBufSize + LogDestFile{..} -> newFileLoggerSet defaultBufSize logDestFile + return $ Yesod.Logger loggerSet tgetter + mkLogger' = liftIO $ do + initialSettings <- readTVarIO appLogSettings + tVar <- newTVarIO =<< mkLogger initialSettings + let updateLogger prevSettings = do + newSettings <- atomically $ do + newSettings <- readTVar appLogSettings + guard $ newSettings /= prevSettings + return newSettings + oldLogger <- atomically . swapTVar tVar =<< mkLogger newSettings + rmLoggerSet $ loggerSet oldLogger + updateLogger newSettings + (tVar, ) <$> fork (updateLogger initialSettings) + appLogger <- over _2 fst <$> allocate mkLogger' (\(tVar, tId) -> killThread tId >> (readTVarIO tVar >>= rmLoggerSet . loggerSet)) + + let appStatic = embeddedStatic + + appInstanceID <- liftIO $ maybe UUID.nextRandom (either readInstanceIDFile return) appInitialInstanceID + + appJobCtl <- liftIO $ newTVarIO Map.empty + appCronThread <- liftIO newEmptyTMVarIO -- We need a log function to create a connection pool. We need a connection -- pool to create our foundation. And we need our foundation to get a -- logging function. To get out of this loop, we initially create a -- temporary foundation without a real connection pool, get a log function -- from there, and then create the real foundation. - let mkFoundation appConnPool = UniWorX {..} + let mkFoundation appConnPool appSmtpPool appLdapPool appCryptoIDKey appSessionKey appSecretBoxKey appWidgetMemcached = UniWorX {..} -- The UniWorX {..} syntax is an example of record wild cards. For more -- information, see: -- https://ocharles.org.uk/blog/posts/2014-12-04-record-wildcards.html - tempFoundation = mkFoundation $ error "connPool forced in tempFoundation" - logFunc = messageLoggerSource tempFoundation appLogger + tempFoundation = mkFoundation + (error "connPool forced in tempFoundation") + (error "smtpPool forced in tempFoundation") + (error "ldapPool forced in tempFoundation") + (error "cryptoIDKey forced in tempFoundation") + (error "sessionKey forced in tempFoundation") + (error "secretBoxKey forced in tempFoundation") + (error "widgetMemcached forced in tempFoundation") + logFunc loc src lvl str = do + f <- messageLoggerSource tempFoundation <$> readTVarIO (snd appLogger) + f loc src lvl str - -- Create the database connection pool - pool <- flip runLoggingT logFunc $ createPostgresqlPool + flip runLoggingT logFunc $ do + $logDebugS "InstanceID" $ UUID.toText appInstanceID + -- logDebugS "Configuration" $ tshow appSettings + + smtpPool <- traverse createSmtpPool appSmtpConf + + appWidgetMemcached <- traverse createWidgetMemcached appWidgetMemcachedConf + + -- Create the database connection pool + sqlPool <- createPostgresqlPool (pgConnStr appDatabaseConf) (pgPoolSize appDatabaseConf) - -- Perform database migration using our application's logging settings. - runLoggingT (runSqlPool migrateAll pool) logFunc + ldapPool <- for appLdapConf $ \LdapConf{..} -> createLdapPool ldapHost ldapPort (poolStripes ldapPool) (poolTimeout ldapPool) ldapTimeout (poolLimit ldapPool) + + -- Perform database migration using our application's logging settings. + migrateAll `runSqlPool` sqlPool + appCryptoIDKey <- clusterSetting (Proxy :: Proxy 'ClusterCryptoIDKey) `runSqlPool` sqlPool + appSessionKey <- clusterSetting (Proxy :: Proxy 'ClusterClientSessionKey) `runSqlPool` sqlPool + appSecretBoxKey <- clusterSetting (Proxy :: Proxy 'ClusterSecretBoxKey) `runSqlPool` sqlPool - -- Return the foundation - return $ mkFoundation pool + let foundation = mkFoundation sqlPool smtpPool ldapPool appCryptoIDKey appSessionKey appSecretBoxKey appWidgetMemcached + + handleJobs foundation + + -- Return the foundation + return foundation + +clusterSetting :: forall key m p. + ( MonadIO m + , ClusterSetting key + , MonadLogger m + ) + => p (key :: ClusterSettingsKey) + -> ReaderT SqlBackend m (ClusterSettingValue key) +clusterSetting proxy@(knownClusterSetting -> key) = do + current' <- get (ClusterConfigKey key) + case Aeson.fromJSON . clusterConfigValue <$> current' of + Just (Aeson.Success c) -> return c + Just (Aeson.Error str) -> do + $logErrorS "clusterSetting" $ "Could not parse JSON-Value for " <> toPathPiece key <> ": " <> pack str + liftIO exitFailure + Nothing -> do + new <- initClusterSetting proxy + void . insert $ ClusterConfig key (Aeson.toJSON new) + return new + +readInstanceIDFile :: MonadIO m => FilePath -> m UUID +readInstanceIDFile idFile = liftIO . handle generateInstead $ LBS.readFile idFile >>= parseBS + where + parseBS :: LBS.ByteString -> IO UUID + parseBS = maybe (throwString "appInstanceIDFile does not contain an UUID encoded in network byte order") return . UUID.fromByteString + generateInstead :: IOException -> IO UUID + generateInstead e + | isDoesNotExistError e = do + createDirectoryIfMissing True $ takeDirectory idFile + instanceId <- UUID.nextRandom + LBS.writeFile idFile $ UUID.toByteString instanceId + return instanceId + | otherwise = throw e + +createSmtpPool :: MonadLoggerIO m => SmtpConf -> m SMTPPool +createSmtpPool SmtpConf{ smtpPool = ResourcePoolConf{..}, .. } = do + logFunc <- askLoggerIO + let + withLogging :: LoggingT IO a -> IO a + withLogging = flip runLoggingT logFunc + + mkConnection = withLogging $ do + $logInfoS "SMTP" "Opening new connection" + liftIO mkConnection' + mkConnection' + | SmtpSslNone <- smtpSsl = connectSMTPPort smtpHost smtpPort + | SmtpSslSmtps <- smtpSsl = connectSMTPSSLWithSettings smtpHost $ defaultSettingsWithPort smtpPort + | SmtpSslStarttls <- smtpSsl = connectSMTPSTARTTLSWithSettings smtpHost $ defaultSettingsWithPort smtpPort + reapConnection conn = withLogging $ do + $logDebugS "SMTP" "Closing connection" + liftIO $ closeSMTP conn + applyAuth :: SmtpAuthConf -> SMTPConnection -> IO SMTPConnection + applyAuth SmtpAuthConf{..} conn = withLogging $ do + $logDebugS "SMTP" "Doing authentication" + authSuccess <- liftIO $ SMTP.authenticate smtpAuthType smtpAuthUsername smtpAuthPassword conn + unless authSuccess $ + fail "SMTP authentication failed" + return conn + liftIO $ createPool (mkConnection >>= maybe return applyAuth smtpAuth) reapConnection poolStripes poolTimeout poolLimit + +createWidgetMemcached :: (MonadLogger m, MonadResource m) => WidgetMemcachedConf -> m Memcached.Connection +createWidgetMemcached WidgetMemcachedConf{widgetMemcachedConnectInfo} = snd <$> allocate (Memcached.connect widgetMemcachedConnectInfo) Memcached.close -- | Convert our foundation to a WAI Application by calling @toWaiAppPlain@ and -- applying some additional middlewares. -makeApplication :: UniWorX -> IO Application -makeApplication foundation = do +makeApplication :: MonadIO m => UniWorX -> m Application +makeApplication foundation = liftIO $ do logWare <- makeLogWare foundation -- Create the WAI application and apply middlewares appPlain <- toWaiAppPlain foundation return $ logWare $ defaultMiddlewaresNoLogging appPlain -makeLogWare :: UniWorX -> IO Middleware -makeLogWare foundation = - mkRequestLogger def - { outputFormat = - if appDetailedRequestLogging $ appSettings foundation - then Detailed True - else Apache - (if appIpFromHeader $ appSettings foundation - then FromFallback - else FromSocket) - , destination = Logger $ loggerSet $ appLogger foundation - } +makeLogWare :: MonadIO m => UniWorX -> m Middleware +makeLogWare app = do + logWareMap <- liftIO $ newTVarIO HashMap.empty + let + mkLogWare ls@LogSettings{..} = do + logger <- readTVarIO . snd $ appLogger app + logWare <- mkRequestLogger def + { outputFormat = bool + (Apache . bool FromSocket FromHeader . appIpFromHeader $ appSettings app) + (Detailed True) + logDetailed + , destination = Logger $ loggerSet logger + } + atomically . modifyTVar' logWareMap $ HashMap.insert ls logWare + return logWare + + void. liftIO $ + mkLogWare =<< readTVarIO (appLogSettings app) + + return $ \wai req fin -> do + lookupRes <- atomically $ do + ls <- readTVar $ appLogSettings app + existing <- HashMap.lookup ls <$> readTVar logWareMap + return $ maybe (Left ls) Right existing + logWare <- either mkLogWare return lookupRes + logWare wai req fin -- | Warp settings for the given foundation value. warpSettings :: UniWorX -> Settings -warpSettings foundation = - setPort (appPort $ appSettings foundation) - $ setHost (appHost $ appSettings foundation) - $ setOnException (\_req e -> - when (defaultShouldDisplayException e) $ messageLoggerSource +warpSettings foundation = defaultSettings + & setPort (appPort $ appSettings foundation) + & setHost (appHost $ appSettings foundation) + & setOnException (\_req e -> + when (defaultShouldDisplayException e) $ do + logger <- readTVarIO . snd $ appLogger foundation + messageLoggerSource foundation - (appLogger foundation) + logger $(qLocation >>= liftLoc) "yesod" LevelError (toLogStr $ "Exception from Warp: " ++ show e)) - defaultSettings -- | For yesod devel, return the Warp settings and WAI Application. -getApplicationDev :: IO (Settings, Application) +getApplicationDev :: (MonadResource m, MonadBaseControl IO m) => m (Settings, Application) getApplicationDev = do settings <- getAppDevSettings foundation <- makeFoundation settings - wsettings <- getDevSettings $ warpSettings foundation + wsettings <- liftIO . getDevSettings $ warpSettings foundation app <- makeApplication foundation return (wsettings, app) -getAppDevSettings :: IO AppSettings -getAppDevSettings = loadYamlSettings [configSettingsYml] [configSettingsYmlValue] useEnv +getAppDevSettings :: MonadIO m => m AppSettings +getAppDevSettings = liftIO $ loadYamlSettings [configSettingsYml] [configSettingsYmlValue] useEnv -- | main function for use by yesod devel develMain :: IO () -develMain = develMainHelper getApplicationDev +develMain = runResourceT $ + liftIO . develMainHelper . return =<< getApplicationDev -- | The @main@ function for an executable running this site. -appMain :: IO () -appMain = do +appMain :: MonadResourceBase m => m () +appMain = runResourceT $ do -- Get the settings from all relevant sources - settings <- loadYamlSettingsArgs + settings <- liftIO $ + loadYamlSettingsArgs -- fall back to compile-time values, set to [] to require values at runtime [configSettingsYmlValue] @@ -176,22 +334,36 @@ appMain = do app <- makeApplication foundation -- Run the application with Warp - runSettings (warpSettings foundation) app + liftIO $ runSettings (warpSettings foundation) app -------------------------------------------------------------- -- Functions for DevelMain.hs (a way to run the app from GHCi) -------------------------------------------------------------- -getApplicationRepl :: IO (Int, UniWorX, Application) +foundationStoreNum :: Word32 +foundationStoreNum = 2 + +getApplicationRepl :: (MonadResource m, MonadBaseControl IO m) => m (Int, UniWorX, Application) getApplicationRepl = do settings <- getAppDevSettings foundation <- makeFoundation settings - wsettings <- getDevSettings $ warpSettings foundation + wsettings <- liftIO . getDevSettings $ warpSettings foundation app1 <- makeApplication foundation + + let foundationStore = Store foundationStoreNum + liftIO $ deleteStore foundationStore + liftIO $ writeStore foundationStore foundation + return (getPort wsettings, foundation, app1) -shutdownApp :: UniWorX -> IO () -shutdownApp _ = return () +shutdownApp :: MonadIO m => UniWorX -> m () +shutdownApp app = do + stopJobCtl app + liftIO $ do + for_ (appWidgetMemcached app) Memcached.close + for_ (appSmtpPool app) destroyAllResources + destroyAllResources $ appConnPool app + release . fst $ appLogger app --------------------------------------------- @@ -200,7 +372,7 @@ shutdownApp _ = return () -- | Run a handler handler :: Handler a -> IO a -handler h = getAppDevSettings >>= makeFoundation >>= flip unsafeHandler h +handler h = runResourceT $ getAppDevSettings >>= makeFoundation >>= liftIO . flip unsafeHandler h -- | Run DB queries db :: ReaderT SqlBackend (HandlerT UniWorX IO) a -> IO a @@ -209,7 +381,7 @@ db = handler . runDB addPWEntry :: User -> Text {-^ Password -} -> IO () -addPWEntry User{..} (Text.encodeUtf8 -> pw) = db $ do +addPWEntry User{ userAuthentication = _, ..} (Text.encodeUtf8 -> pw) = db $ do PWHashConf{..} <- getsYesod $ appAuthPWHash . appSettings (AuthPWHash . Text.decodeUtf8 -> userAuthentication) <- liftIO $ makePasswordWith pwHashAlgorithm pw pwHashStrength void $ insert User{..} diff --git a/src/Auth/Dummy.hs b/src/Auth/Dummy.hs index 809db8647..e7033f3d8 100644 --- a/src/Auth/Dummy.hs +++ b/src/Auth/Dummy.hs @@ -1,11 +1,3 @@ -{-# LANGUAGE NoImplicitPrelude - , RecordWildCards - , TemplateHaskell - , FlexibleContexts - , TypeFamilies - , OverloadedStrings - #-} - module Auth.Dummy ( dummyLogin , DummyMessage(..) @@ -21,14 +13,15 @@ import qualified Data.CaseInsensitive as CI data DummyMessage = MsgDummyIdent + | MsgDummyNoFormData + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable) dummyForm :: ( RenderMessage site FormMessage , RenderMessage site DummyMessage , YesodPersist site , SqlBackendCanRead (YesodPersistBackend site) - , Button site SubmitButton - , Show (ButtonCssClass site) + , Button site ButtonSubmit ) => AForm (HandlerT site IO) (CI Text) dummyForm = areq (selectField userList) (fslI MsgDummyIdent) Nothing <* submitButton @@ -41,8 +34,7 @@ dummyLogin :: ( YesodAuth site , SqlBackendCanRead (YesodPersistBackend site) , RenderMessage site FormMessage , RenderMessage site DummyMessage - , Button site SubmitButton - , Show (ButtonCssClass site) + , Button site ButtonSubmit ) => AuthPlugin site dummyLogin = AuthPlugin{..} where @@ -54,7 +46,9 @@ dummyLogin = AuthPlugin{..} FormFailure errs -> do lift . forM_ errs $ addMessage Error . toHtml redirect LoginR - FormMissing -> redirect LoginR + FormMissing -> do + lift $ addMessageI Warning MsgDummyNoFormData + redirect LoginR FormSuccess ident -> lift . setCredsRedirect $ Creds "dummy" (CI.original ident) [] apDispatch _ _ = notFound diff --git a/src/Auth/LDAP.hs b/src/Auth/LDAP.hs index 32c185519..cd2a9a037 100644 --- a/src/Auth/LDAP.hs +++ b/src/Auth/LDAP.hs @@ -1,14 +1,3 @@ -{-# LANGUAGE RecordWildCards - , OverloadedStrings - , TemplateHaskell - , ViewPatterns - , TypeFamilies - , FlexibleContexts - , FlexibleInstances - , NoImplicitPrelude - , ScopedTypeVariables - #-} - module Auth.LDAP ( campusLogin , CampusUserException(..) @@ -28,6 +17,7 @@ import qualified Control.Monad.Catch as Exc import Utils.Form +import Ldap.Client (Ldap) import qualified Ldap.Client as Ldap import qualified Data.Text.Encoding as Text @@ -38,23 +28,24 @@ import qualified Yesod.Auth.Message as Msg data CampusLogin = CampusLogin { campusIdent :: CI Text , campusPassword :: Text - } + } deriving (Generic, Typeable) data CampusMessage = MsgCampusIdentNote | MsgCampusIdent | MsgCampusPassword | MsgCampusSubmit | MsgCampusInvalidCredentials + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable) -findUser :: LdapConf -> Ldap.Ldap -> Text -> [Ldap.Attr] -> IO [Ldap.SearchEntry] +findUser :: LdapConf -> Ldap -> Text -> [Ldap.Attr] -> IO [Ldap.SearchEntry] findUser LdapConf{..} ldap campusIdent = Ldap.search ldap ldapBase userSearchSettings userFilter where userFilter = userPrincipalName Ldap.:= Text.encodeUtf8 campusIdent userSearchSettings = mconcat [ Ldap.scope ldapScope , Ldap.size 2 - , Ldap.time ldapTimeout + , Ldap.time ldapSearchTimeout , Ldap.derefAliases Ldap.DerefAlways ] @@ -63,8 +54,7 @@ userPrincipalName = Ldap.Attr "userPrincipalName" campusForm :: ( RenderMessage site FormMessage , RenderMessage site CampusMessage - , Button site SubmitButton - , Show (ButtonCssClass site) + , Button site ButtonSubmit ) => AForm (HandlerT site IO) CampusLogin campusForm = CampusLogin <$> areq ciField (fslpI MsgCampusIdent "user.name@campus.lmu.de" & setTooltip MsgCampusIdentNote) Nothing @@ -75,10 +65,9 @@ campusLogin :: forall site. ( YesodAuth site , RenderMessage site FormMessage , RenderMessage site CampusMessage - , Button site SubmitButton - , Show (ButtonCssClass site) - ) => LdapConf -> AuthPlugin site -campusLogin conf@LdapConf{..} = AuthPlugin{..} + , Button site ButtonSubmit + ) => LdapConf -> LdapPool -> AuthPlugin site +campusLogin conf@LdapConf{..} pool = AuthPlugin{..} where apName = "LDAP" apDispatch :: Text -> [Text] -> HandlerT Auth (HandlerT site IO) TypedContent @@ -90,13 +79,13 @@ campusLogin conf@LdapConf{..} = AuthPlugin{..} redirect LoginR FormMissing -> redirect LoginR FormSuccess CampusLogin{ campusIdent = CI.original -> campusIdent, ..} -> do - ldapResult <- liftIO . Ldap.with ldapHost ldapPort $ \ldap -> do + ldapResult <- withLdap pool $ \ldap -> do Ldap.bind ldap (Ldap.Dn campusIdent) (Ldap.Password $ Text.encodeUtf8 campusPassword) Ldap.bind ldap ldapDn ldapPassword findUser conf ldap campusIdent [userPrincipalName] case ldapResult of Left err - | Ldap.ResponseError (Ldap.ResponseErrorCode _ Ldap.InvalidCredentials _ _) <- err + | LdapError (Ldap.ResponseError (Ldap.ResponseErrorCode _ Ldap.InvalidCredentials _ _)) <- err -> do $logDebugS "LDAP" "Invalid credentials" loginErrorMessageI LoginR Msg.InvalidLogin @@ -118,18 +107,18 @@ campusLogin conf@LdapConf{..} = AuthPlugin{..} (login, loginEnctype) <- handlerToWidget . generateFormPost $ renderAForm FormStandard campusForm $(widgetFile "widgets/campus-login-form") -data CampusUserException = CampusUserLdapError Ldap.LdapError +data CampusUserException = CampusUserLdapError LdapPoolError | CampusUserHostNotResolved String | CampusUserLineTooLong | CampusUserHostCannotConnect String [IOException] | CampusUserNoResult | CampusUserAmbiguous - deriving (Show, Eq, Typeable) + deriving (Show, Eq, Generic, Typeable) instance Exception CampusUserException -campusUser :: (MonadIO m, MonadThrow m) => LdapConf -> Creds site -> m (Ldap.AttrList []) -campusUser conf@LdapConf{..} Creds{..} = liftIO . (`catches` errHandlers) $ either (throwM . CampusUserLdapError) return <=< Ldap.with ldapHost ldapPort $ \ldap -> do +campusUser :: (MonadBaseControl IO m, MonadThrow m, MonadIO m) => LdapConf -> LdapPool -> Creds site -> m (Ldap.AttrList []) +campusUser conf@LdapConf{..} pool Creds{..} = liftIO . (`catches` errHandlers) $ either (throwM . CampusUserLdapError) return <=< withLdap pool $ \ldap -> do Ldap.bind ldap ldapDn ldapPassword results <- case lookup "DN" credsExtra of Just userDN -> do @@ -137,7 +126,7 @@ campusUser conf@LdapConf{..} Creds{..} = liftIO . (`catches` errHandlers) $ eith userSearchSettings = mconcat [ Ldap.scope Ldap.BaseObject , Ldap.size 2 - , Ldap.time ldapTimeout + , Ldap.time ldapSearchTimeout , Ldap.derefAliases Ldap.DerefAlways ] Ldap.search ldap (Ldap.Dn userDN) userSearchSettings userFilter [] diff --git a/src/Auth/PWHash.hs b/src/Auth/PWHash.hs index ba7198710..68df34703 100644 --- a/src/Auth/PWHash.hs +++ b/src/Auth/PWHash.hs @@ -1,13 +1,3 @@ -{-# LANGUAGE NoImplicitPrelude - , QuasiQuotes - , TemplateHaskell - , ViewPatterns - , RecordWildCards - , OverloadedStrings - , FlexibleContexts - , TypeFamilies - #-} - module Auth.PWHash ( hashLogin , PWHashMessage(..) @@ -29,16 +19,16 @@ import qualified Yesod.Auth.Message as Msg data HashLogin = HashLogin { hashIdent :: CI Text , hashPassword :: Text - } + } deriving (Generic, Typeable) data PWHashMessage = MsgPWHashIdent | MsgPWHashPassword + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable) hashForm :: ( RenderMessage site FormMessage , RenderMessage site PWHashMessage - , Button site SubmitButton - , Show (ButtonCssClass site) + , Button site ButtonSubmit ) => AForm (HandlerT site IO) HashLogin hashForm = HashLogin <$> areq ciField (fslpI MsgPWHashIdent "Identifikation") Nothing @@ -51,8 +41,7 @@ hashLogin :: ( YesodAuth site , SqlBackendCanRead (YesodPersistBackend site) , RenderMessage site FormMessage , RenderMessage site PWHashMessage - , Button site SubmitButton - , Show (ButtonCssClass site) + , Button site ButtonSubmit ) => PWHashAlgorithm -> AuthPlugin site hashLogin pwHashAlgo = AuthPlugin{..} where diff --git a/src/Cron.hs b/src/Cron.hs new file mode 100644 index 000000000..53a7a01b3 --- /dev/null +++ b/src/Cron.hs @@ -0,0 +1,254 @@ +module Cron + ( evalCronMatch + , CronNextMatch(..) + , nextCronMatch + , module Cron.Types + ) where + +import ClassyPrelude +import Prelude (lcm) +import Cron.Types + +import Data.Time +import Data.Time.Calendar.OrdinalDate (toOrdinalDate, fromOrdinalDateValid) +import Data.Time.Calendar.WeekDate (toWeekDate, fromWeekDate, fromWeekDateValid) +import Data.Time.Zones + +import Numeric.Natural +import Data.Ratio ((%)) + +import qualified Data.Set as Set + +import Utils.Lens hiding (from, to) + + +data CronDate = CronDate + { cdYear, cdWeekOfYear, cdDayOfYear + , cdMonth, cdWeekOfMonth, cdDayOfMonth + , cdDayOfWeek + , cdHour, cdMinute, cdSecond :: Natural + } deriving (Eq, Show, Read) + +makeLenses_ ''CronDate + + +evalCronMatch :: CronMatch -> Natural -> Bool +evalCronMatch CronMatchAny _ = True +evalCronMatch CronMatchNone _ = False +evalCronMatch (CronMatchSome xs) x = Set.member x $ toNullable xs +evalCronMatch (CronMatchStep step) x = (x `mod` step) == 0 +evalCronMatch (CronMatchContiguous from to) x = from <= x && x <= to +evalCronMatch (CronMatchIntersect a b) x = evalCronMatch a x && evalCronMatch b x +evalCronMatch (CronMatchUnion a b) x = evalCronMatch a x || evalCronMatch b x + +toCronDate :: LocalTime -> CronDate +toCronDate LocalTime{..} = CronDate{..} + where + (fromInteger -> cdYear, fromIntegral -> cdMonth, fromIntegral -> cdDayOfMonth) + = toGregorian localDay + (_, fromIntegral -> cdDayOfYear) + = toOrdinalDate localDay + (_, fromIntegral -> cdWeekOfYear, fromIntegral -> cdDayOfWeek) + = toWeekDate localDay + cdWeekOfMonth = go 1 localDay + where + go :: Natural -> Day -> Natural + go n day + | dow /= 4 = go n $ fromWeekDate y w 4 -- According to ISO week of month is determined by Thursday + | m == m' = go (succ n) day' + | otherwise = n + where + (y, w, dow) = toWeekDate day + day' + | w /= 0 = fromWeekDate y (pred w) dow + | otherwise = fromWeekDate (pred y) 53 dow + (_, m, _) = toGregorian day + (_, m', _) = toGregorian day' + TimeOfDay + { todHour = fromIntegral -> cdHour + , todMin = fromIntegral -> cdMinute + , todSec = round -> cdSecond + } = localTimeOfDay + +consistentCronDate :: CronDate -> Bool +consistentCronDate cd@CronDate{ cdWeekOfMonth = _, ..} = fromMaybe False $ do + gDay <- fromGregorianValid (fromIntegral cdYear) (fromIntegral cdMonth) (fromIntegral cdDayOfMonth) + wDay <- fromWeekDateValid (fromIntegral cdYear) (fromIntegral cdWeekOfYear) (fromIntegral cdDayOfWeek) + guard $ gDay == wDay + oDay <- fromOrdinalDateValid (fromIntegral cdYear) (fromIntegral cdDayOfYear) + guard $ wDay == oDay + guard $ ((==) `on` cdWeekOfMonth) cd (toCronDate $ LocalTime wDay (error "TimeOfDay inspected in toCronDate")) + return True + + +data CronNextMatch a = MatchAsap | MatchAt a | MatchNone + deriving (Eq, Ord, Show, Read, Functor) + +instance Applicative CronNextMatch where + pure = MatchAt + _ <*> MatchNone = MatchNone + MatchNone <*> _ = MatchNone + _ <*> MatchAsap = MatchAsap + MatchAsap <*> _ = MatchAsap + MatchAt f <*> MatchAt x = MatchAt $ f x + +instance Alternative CronNextMatch where + empty = MatchNone + x <|> MatchNone = x + MatchNone <|> x = x + _ <|> MatchAsap = MatchAsap + MatchAsap <|> _ = MatchAsap + (MatchAt a) <|> (MatchAt _) = MatchAt a + + +listToMatch :: [a] -> CronNextMatch a +listToMatch [] = MatchNone +listToMatch (t:_) = MatchAt t + +genMatch :: Int -- ^ Period + -> Bool -- ^ Modular + -> Natural -- ^ Start value + -> CronMatch + -> [Natural] +genMatch p m st CronMatchAny = take p $ map (bool id (succ . (`mod` fromIntegral p)) m) [st..] +genMatch _ _ _ CronMatchNone = [] +genMatch p m _ (CronMatchSome xs) = take p . map (bool id (succ . (`mod` fromIntegral p)) m) . Set.toAscList $ toNullable xs +genMatch p m st (CronMatchStep step) = do + start <- [st..st + step] + guard $ (start `mod` step) == 0 + take (ceiling $ fromIntegral p % step) $ map (bool id (succ . (`mod` fromIntegral p)) m) [start,start + step..] +genMatch p m st (CronMatchContiguous from to) = take p . map (bool id (succ . (`mod` fromIntegral p)) m) $ [max st from..to] +genMatch _ _ _ (CronMatchIntersect CronMatchNone _) = [] +genMatch _ _ _ (CronMatchIntersect _ CronMatchNone) = [] +genMatch p m st (CronMatchIntersect CronMatchAny other) = genMatch p m st other +genMatch p m st (CronMatchIntersect other CronMatchAny) = genMatch p m st other +genMatch p m st (CronMatchIntersect (CronMatchStep st1) (CronMatchStep st2)) + = genMatch p m st . CronMatchStep $ lcm st1 st2 +genMatch p m st (CronMatchIntersect aGen bGen) + | [] <- as' = [] + | (a:as) <- as' = mergeAnd (a:as) (genMatch p m a bGen) + where + as' = genMatch p m st aGen + mergeAnd [] _ = [] + mergeAnd _ [] = [] + mergeAnd (a:as) (b:bs) + | a < b = mergeAnd as (b:bs) + | a == b = a : mergeAnd as bs + | otherwise = mergeAnd (a:as) bs +genMatch p m st (CronMatchUnion CronMatchNone other) = genMatch p m st other +genMatch p m st (CronMatchUnion other CronMatchNone) = genMatch p m st other +genMatch p m st (CronMatchUnion CronMatchAny _) = genMatch p m st CronMatchAny +genMatch p m st (CronMatchUnion _ CronMatchAny) = genMatch p m st CronMatchAny +genMatch p m st (CronMatchUnion aGen bGen) = merge (genMatch p m st aGen) (genMatch p m st bGen) + where + merge [] bs = bs + merge as [] = as + merge (a:as) (b:bs) + | a < b = a : merge as (b:bs) + | a == b = a : merge as bs + | otherwise = b : merge (a:as) bs + +nextCronMatch :: TZ -- ^ Timezone of the `Cron`-Entry + -> Maybe UTCTime -- ^ Time of last execution of the job + -> NominalDiffTime -- ^ Scheduling precision + -> UTCTime -- ^ Current time, used only for `CronCalendar` + -> Cron + -> CronNextMatch UTCTime +nextCronMatch tz mPrev prec now c@Cron{..} = case notAfter of + MatchAsap -> MatchNone + MatchAt ts + | MatchAt ts' <- nextMatch + , ts' <= ts -> MatchAt ts' + | MatchAsap <- nextMatch + , now <= ts -> MatchAsap + | otherwise -> MatchNone + MatchNone -> nextMatch + where + notAfter + | Right c' <- cronNotAfter + , Just ref <- notAfterRef + = execRef' ref False c' + | Left diff <- cronNotAfter + , Just ref <- notAfterRef + = MatchAt $ diff `addUTCTime` ref + | otherwise = MatchNone + notAfterRef + | Just prevT <- mPrev = Just prevT + | otherwise = case execRef' now False cronInitial of + MatchAsap -> error "execRef' should not return MatchAsap" + MatchAt t -> Just t + MatchNone -> Nothing + nextMatch = case mPrev of + Nothing + -> execRef now False cronInitial + Just prevT + -> case cronRepeat of + CronRepeatOnChange + | not $ matchesCron tz Nothing prec prevT c + -> let + cutoffTime = addUTCTime cronRateLimit prevT + in case execRef now False cronInitial of + MatchAsap + | now < cutoffTime -> MatchAt cutoffTime + MatchAt ts + | ts < cutoffTime -> MatchAt cutoffTime + other -> other + CronRepeatScheduled cronNext + -> case cronNext of + CronAsap + | addUTCTime cronRateLimit prevT <= now + -> MatchAsap + | otherwise + -> MatchAt $ addUTCTime cronRateLimit prevT + _other + -> execRef (addUTCTime cronRateLimit prevT) True cronNext + _other -> MatchNone + + execRef ref wasExecd cronAbsolute = case execRef' ref wasExecd cronAbsolute of + MatchAt t + | t <= ref -> MatchAsap + other -> other + + execRef' ref wasExecd cronAbsolute = case cronAbsolute of + CronAsap -> MatchAt ref + CronTimestamp{ cronTimestamp = localTimeToUTCTZ tz -> ts } + | ref <= ts || not wasExecd -> MatchAt ts + | otherwise -> MatchNone + CronCalendar{..} -> listToMatch $ do + let CronDate{..} = toCronDate $ utcToLocalTimeTZ tz ref + + mCronYear <- genMatch 400 False cdYear cronYear + mCronWeekOfYear <- genMatch 53 True cdWeekOfYear cronWeekOfYear + mCronDayOfYear <- genMatch 366 True cdDayOfYear cronDayOfYear + mCronMonth <- genMatch 12 True cdMonth cronMonth + mCronWeekOfMonth <- genMatch 5 True cdWeekOfMonth cronWeekOfMonth + mCronDayOfMonth <- genMatch 31 True cdDayOfMonth cronDayOfMonth + mCronDayOfWeek <- genMatch 7 True cdDayOfWeek cronDayOfWeek + mCronHour <- genMatch 24 True cdHour cronHour + mCronMinute <- genMatch 60 True cdMinute cronMinute + mCronSecond <- genMatch 60 True cdSecond cronSecond + guard $ consistentCronDate CronDate + { cdYear = mCronYear, cdMonth = mCronMonth, cdDayOfMonth = mCronDayOfMonth + , cdHour = mCronHour, cdMinute = mCronMinute, cdSecond = mCronSecond + , cdWeekOfYear = mCronWeekOfYear, cdWeekOfMonth = mCronWeekOfMonth + , cdDayOfYear = mCronDayOfYear, cdDayOfWeek = mCronDayOfWeek + } + + localDay <- maybeToList $ fromGregorianValid (fromIntegral mCronYear) (fromIntegral mCronMonth) (fromIntegral mCronDayOfMonth) + let localTimeOfDay = TimeOfDay (fromIntegral mCronHour) (fromIntegral mCronMinute) (fromIntegral mCronSecond) + return $ localTimeToUTCTZ tz LocalTime{..} + CronNotScheduled -> MatchNone + +matchesCron :: TZ -- ^ Timezone of the `Cron`-Entry + -> Maybe UTCTime -- ^ Previous execution of the job + -> NominalDiffTime -- ^ Scheduling precision + -> UTCTime -- ^ "Current" time + -> Cron + -> Bool +-- ^ @matchesCron tz prev prec now c@ determines whether the given `Cron` +-- specification @c@ should match @now@, under the assumption that the next +-- check will occur no earlier than @now + prec@. +matchesCron tz mPrev prec now cron = case nextCronMatch tz mPrev prec now cron of + MatchAsap -> True + MatchNone -> False + MatchAt ts -> ts <= addUTCTime prec now diff --git a/src/Cron/Types.hs b/src/Cron/Types.hs new file mode 100644 index 000000000..ab3e92972 --- /dev/null +++ b/src/Cron/Types.hs @@ -0,0 +1,61 @@ +module Cron.Types + ( Cron(..), Crontab + , CronMatch(..) + , CronAbsolute(..) + , CronRepeat(..) + ) where + +import ClassyPrelude + +import Utils.Lens.TH + +import Data.Time + +import Numeric.Natural + +import Data.HashMap.Strict (HashMap) + + +data CronMatch + = CronMatchAny + | CronMatchNone + | CronMatchSome (NonNull (Set Natural)) + | CronMatchStep Natural + | CronMatchContiguous Natural Natural + | CronMatchIntersect CronMatch CronMatch + | CronMatchUnion CronMatch CronMatch + deriving (Eq, Show, Read) + +data CronAbsolute + = CronAsap + | CronTimestamp + { cronTimestamp :: LocalTime + } + | CronCalendar + { cronYear, cronWeekOfYear, cronDayOfYear + , cronMonth, cronWeekOfMonth, cronDayOfMonth + , cronDayOfWeek + , cronHour, cronMinute, cronSecond :: CronMatch + } + | CronNotScheduled + deriving (Eq, Show, Read) + +makeLenses_ ''CronAbsolute + +data CronRepeat + = CronRepeatOnChange + | CronRepeatScheduled CronAbsolute + | CronRepeatNever + deriving (Eq, Show, Read) + +data Cron = Cron + { cronInitial :: CronAbsolute + , cronRepeat :: CronRepeat + , cronRateLimit :: NominalDiffTime + , cronNotAfter :: Either NominalDiffTime CronAbsolute + } + deriving (Eq, Show) + +makeLenses_ ''Cron + +type Crontab a = HashMap a Cron diff --git a/src/CryptoID.hs b/src/CryptoID.hs index e2f6361cb..58fa1a09a 100644 --- a/src/CryptoID.hs +++ b/src/CryptoID.hs @@ -1,11 +1,3 @@ -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE NoImplicitPrelude #-} -{-# LANGUAGE TemplateHaskell #-} -{-# LANGUAGE TypeFamilies #-} -{-# LANGUAGE FlexibleInstances, TypeSynonymInstances #-} -{-# LANGUAGE RecordWildCards, ViewPatterns, PatternGuards #-} -{-# LANGUAGE PatternSynonyms #-} {-# OPTIONS_GHC -fno-warn-orphans #-} module CryptoID @@ -17,7 +9,7 @@ module CryptoID import CryptoID.TH -import ClassyPrelude hiding (fromString) +import ClassyPrelude import Model import qualified Data.CryptoID as E @@ -33,11 +25,17 @@ import Web.PathPieces import Data.CaseInsensitive (CI) import qualified Data.CaseInsensitive as CI +import Data.Aeson (ToJSON(..), ToJSONKey(..), ToJSONKeyFunction(..), FromJSON(..), FromJSONKey(..), FromJSONKeyFunction(..), Value(..), withText) +import Data.Aeson.Encoding (text) + -- Generates CryptoUUID... and CryptoFileName... Datatypes decCryptoIDs [ ''SubmissionId , ''FileId , ''UserId + , ''SheetId + , ''SystemMessageId + , ''SystemMessageTranslationId ] instance {-# OVERLAPS #-} namespace ~ CryptoIDNamespace (CI FilePath) SubmissionId => PathPiece (E.CryptoID namespace (CI FilePath)) where @@ -46,6 +44,15 @@ instance {-# OVERLAPS #-} namespace ~ CryptoIDNamespace (CI FilePath) Submission return . CryptoID . CI.mk $ map CI.original piece' toPathPiece = Text.pack . ("uwa" <>) . CI.foldedCase . ciphertext +instance {-# OVERLAPS #-} namespace ~ CryptoIDNamespace (CI FilePath) SubmissionId => ToJSON (E.CryptoID namespace (CI FilePath)) where + toJSON = String . toPathPiece +instance {-# OVERLAPS #-} namespace ~ CryptoIDNamespace (CI FilePath) SubmissionId => ToJSONKey (E.CryptoID namespace (CI FilePath)) where + toJSONKey = ToJSONKeyText toPathPiece (text . toPathPiece) +instance {-# OVERLAPS #-} namespace ~ CryptoIDNamespace (CI FilePath) SubmissionId => FromJSON (E.CryptoID namespace (CI FilePath)) where + parseJSON = withText "CryptoFileNameSubmission" $ maybe (fail "Could not parse CryptoFileNameSubmission") return . fromPathPiece +instance {-# OVERLAPS #-} namespace ~ CryptoIDNamespace (CI FilePath) SubmissionId => FromJSONKey (E.CryptoID namespace (CI FilePath)) where + fromJSONKey = FromJSONKeyTextParser $ maybe (fail "Could not parse CryptoFileNameSubmission") return . fromPathPiece + newtype SubmissionMode = SubmissionMode (Maybe CryptoFileNameSubmission) deriving (Show, Read, Eq) diff --git a/src/CryptoID/TH.hs b/src/CryptoID/TH.hs index 23122dadf..c3f1e4322 100644 --- a/src/CryptoID/TH.hs +++ b/src/CryptoID/TH.hs @@ -1,7 +1,3 @@ -{-# LANGUAGE NoImplicitPrelude #-} -{-# LANGUAGE TemplateHaskell #-} -{-# LANGUAGE ViewPatterns #-} - module CryptoID.TH where import ClassyPrelude @@ -10,14 +6,11 @@ import Language.Haskell.TH import Data.CryptoID.Class.ImplicitNamespace import Data.UUID.Types (UUID) -import Data.Binary (Binary(..)) import Data.Binary.SerializationLength import Data.CaseInsensitive (CI) import System.FilePath (FilePath) -import Database.Persist.Sql (toSqlKey, fromSqlKey) - decCryptoIDs :: [Name] -> DecsQ decCryptoIDs = fmap concat . mapM decCryptoID @@ -25,9 +18,6 @@ decCryptoIDs = fmap concat . mapM decCryptoID decCryptoID :: Name -> DecsQ decCryptoID n@(conT -> t) = do instances <- [d| - instance Binary $(t) where - get = $(varE 'toSqlKey) <$> get - put = put . $(varE 'fromSqlKey) instance HasFixedSerializationLength $(t) where type SerializationLength $(t) = SerializationLength Int64 diff --git a/src/Data/CaseInsensitive/Instances.hs b/src/Data/CaseInsensitive/Instances.hs index ea5253f44..3986e3cc7 100644 --- a/src/Data/CaseInsensitive/Instances.hs +++ b/src/Data/CaseInsensitive/Instances.hs @@ -1,13 +1,11 @@ -{-# LANGUAGE NoImplicitPrelude #-} -{-# LANGUAGE FlexibleInstances #-} -{-# LANGUAGE MultiParamTypeClasses #-} -{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE UndecidableInstances #-} {-# OPTIONS_GHC -fno-warn-orphans #-} module Data.CaseInsensitive.Instances - () where + ( + ) where -import ClassyPrelude.Yesod +import ClassyPrelude.Yesod hiding (lift) import Data.CaseInsensitive (CI) import qualified Data.CaseInsensitive as CI @@ -15,10 +13,19 @@ import qualified Data.CaseInsensitive as CI import Database.Persist.Sql import Text.Blaze (ToMarkup(..)) +import Text.Shakespeare.Text (ToText(..)) import Data.Text (Text) import qualified Data.Text.Encoding as Text +import Language.Haskell.TH.Syntax (Lift(..)) + +import Data.Aeson (ToJSON(..), FromJSON(..), ToJSONKey(..), FromJSONKey(..), ToJSONKeyFunction(..)) + +import qualified Database.Esqueleto as E + +import Web.HttpApiData + instance PersistField (CI Text) where toPersistValue ciText = PersistDbSpecific . Text.encodeUtf8 $ CI.original ciText @@ -36,12 +43,22 @@ instance PersistFieldSql (CI Text) where instance PersistFieldSql (CI String) where sqlType _ = SqlOther "citext" +instance (E.SqlString a, PersistField (CI a)) => E.SqlString (CI a) + instance ToJSON a => ToJSON (CI a) where toJSON = toJSON . CI.original instance (FromJSON a, CI.FoldCase a) => FromJSON (CI a) where parseJSON = fmap CI.mk . parseJSON +instance (ToJSONKey a, ToJSON a) => ToJSONKey (CI a) where + toJSONKey = case toJSONKey of + ToJSONKeyText toVal toEnc -> ToJSONKeyText (toVal . CI.original) (toEnc . CI.original) + ToJSONKeyValue toVal toEnc -> ToJSONKeyValue (toVal . CI.original) (toEnc . CI.original) + +instance (FromJSON a, FromJSONKey a, CI.FoldCase a) => FromJSONKey (CI a) where + fromJSONKey = CI.mk <$> fromJSONKey + instance ToMessage a => ToMessage (CI a) where toMessage = toMessage . CI.original @@ -49,8 +66,31 @@ instance ToMarkup a => ToMarkup (CI a) where toMarkup = toMarkup . CI.original preEscapedToMarkup = preEscapedToMarkup . CI.original +instance ToText a => ToText (CI a) where + toText = toText . CI.original + instance ToWidget site a => ToWidget site (CI a) where toWidget = toWidget . CI.original instance RenderMessage site a => RenderMessage site (CI a) where renderMessage f ls msg = renderMessage f ls $ CI.original msg + +instance Lift t => Lift (CI t) where + lift (CI.original -> orig) = [e|CI.mk $(lift orig)|] + + +instance (CI.FoldCase s, PathPiece s) => PathPiece (CI s) where + fromPathPiece = fmap CI.mk . fromPathPiece + toPathPiece = toPathPiece . CI.original + +instance ToHttpApiData (CI Text) where + toUrlPiece = toUrlPiece . CI.original + toEncodedUrlPiece = toEncodedUrlPiece . CI.original + +instance FromHttpApiData (CI Text) where + parseUrlPiece = fmap CI.mk . parseUrlPiece + +instance (CI.FoldCase s, PathMultiPiece s) => PathMultiPiece (CI s) where + fromPathMultiPiece = fmap CI.mk . fromPathMultiPiece + toPathMultiPiece = toPathMultiPiece . CI.foldedCase + diff --git a/src/Data/Monoid/Instances.hs b/src/Data/Monoid/Instances.hs new file mode 100644 index 000000000..44909d53f --- /dev/null +++ b/src/Data/Monoid/Instances.hs @@ -0,0 +1,19 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Data.Monoid.Instances + ( + ) where + +import ClassyPrelude +import Data.Monoid + +type instance Element (Dual a) = a +instance MonoPointed (Dual a) +type instance Element (Sum a) = a +instance MonoPointed (Sum a) +type instance Element (Product a) = a +instance MonoPointed (Product a) +type instance Element (First a) = a +instance MonoPointed (First a) +type instance Element (Last a) = a +instance MonoPointed (Last a) diff --git a/src/Data/Universe/Instances/Reverse/Hashable.hs b/src/Data/Universe/Instances/Reverse/Hashable.hs new file mode 100644 index 000000000..d264fa41f --- /dev/null +++ b/src/Data/Universe/Instances/Reverse/Hashable.hs @@ -0,0 +1,13 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Data.Universe.Instances.Reverse.Hashable + ( + ) where + +import ClassyPrelude + +import Data.Universe + + +instance (Hashable a, Hashable b, Finite a) => Hashable (a -> b) where + hashWithSalt s f = s `hashWithSalt` [ (k, f k) | k <- universeF ] diff --git a/src/Data/Universe/Instances/Reverse/JSON.hs b/src/Data/Universe/Instances/Reverse/JSON.hs new file mode 100644 index 000000000..7c8dbb3ed --- /dev/null +++ b/src/Data/Universe/Instances/Reverse/JSON.hs @@ -0,0 +1,27 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Data.Universe.Instances.Reverse.JSON + ( + ) where + +import ClassyPrelude + +import Data.Aeson +import Data.Aeson.Types (Parser) + +import qualified Data.HashSet as HashSet +import qualified Data.HashMap.Strict as HashMap +import Data.HashMap.Strict ((!)) + +import Data.Universe + + +instance (Eq a, Hashable a, Finite a, ToJSON b, ToJSONKey a) => ToJSON (a -> b) where + toJSON f = toJSON $ HashMap.fromList [(k, f k) | k <- universeF] + +instance (Eq a, Hashable a, Finite a, FromJSON b, FromJSONKey a) => FromJSON (a -> b) where + parseJSON val = do + vMap <- parseJSON val :: Parser (HashMap a b) + unless (HashSet.fromMap (HashMap.map (const ()) vMap) == HashSet.fromList universeF) $ + fail "Not all required keys found" + return (vMap !) diff --git a/src/Database/Esqueleto/Instances.hs b/src/Database/Esqueleto/Instances.hs new file mode 100644 index 000000000..c4dabfe41 --- /dev/null +++ b/src/Database/Esqueleto/Instances.hs @@ -0,0 +1,25 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Database.Esqueleto.Instances + ( + ) where + +import ClassyPrelude.Yesod + +import qualified Database.Esqueleto as E + +import Data.Binary (Binary) +import qualified Data.Binary as B + + +instance ToJSON a => ToJSON (E.Value a) where + toJSON = toJSON . E.unValue + +instance FromJSON a => FromJSON (E.Value a) where + parseJSON = fmap E.Value . parseJSON + + +instance Binary a => Binary (E.Value a) where + put = B.put . E.unValue + get = E.Value <$> B.get + putList = B.putList . map E.unValue diff --git a/src/Database/Persist/Sql/Instances.hs b/src/Database/Persist/Sql/Instances.hs new file mode 100644 index 000000000..2d0044164 --- /dev/null +++ b/src/Database/Persist/Sql/Instances.hs @@ -0,0 +1,33 @@ +{-# LANGUAGE UndecidableInstances #-} +{-# OPTIONS_GHC -fno-warn-orphans #-} + +module Database.Persist.Sql.Instances + ( + ) where + +import ClassyPrelude.Yesod + +import Data.Binary (Binary) +import qualified Data.Binary as B + +import Database.Persist.Sql + + +instance Binary (BackendKey SqlWriteBackend) where + put = B.put . unSqlWriteBackendKey + putList = B.putList . map unSqlWriteBackendKey + get = SqlWriteBackendKey <$> B.get +instance Binary (BackendKey SqlReadBackend) where + put = B.put . unSqlReadBackendKey + putList = B.putList . map unSqlReadBackendKey + get = SqlReadBackendKey <$> B.get +instance Binary (BackendKey SqlBackend) where + put = B.put . unSqlBackendKey + putList = B.putList . map unSqlBackendKey + get = SqlBackendKey <$> B.get + + +instance {-# OVERLAPPABLE #-} ToBackendKey SqlBackend record => Binary (Key record) where + put = B.put . fromSqlKey + putList = B.putList . map fromSqlKey + get = toSqlKey <$> B.get diff --git a/src/Database/Persist/TH/Directory.hs b/src/Database/Persist/TH/Directory.hs new file mode 100644 index 000000000..770b71d71 --- /dev/null +++ b/src/Database/Persist/TH/Directory.hs @@ -0,0 +1,27 @@ +module Database.Persist.TH.Directory + ( persistDirectoryWith + ) where + +import ClassyPrelude hiding (mapM_, toList) + +import Database.Persist.TH (parseReferences) +import Database.Persist.Quasi (PersistSettings) +import Language.Haskell.TH.Syntax + +import qualified Data.Text as Text +import qualified Data.Text.IO as Text +import qualified System.IO as SIO + +import qualified System.Directory.Tree as DirTree + +import Data.Foldable (Foldable(..), mapM_) + +persistDirectoryWith :: PersistSettings -> FilePath -> Q Exp +persistDirectoryWith settings dir = do + files <- runIO . flip DirTree.readDirectoryWith dir $ \fp -> do + h <- SIO.openFile fp SIO.ReadMode + SIO.hSetEncoding h SIO.utf8_bom + Text.hGetContents h + mapM_ (qAddDependentFile . fst) $ DirTree.zipPaths files + + parseReferences settings . Text.intercalate "\n" . toList $ DirTree.dirTree files diff --git a/src/Foundation.hs b/src/Foundation.hs index 558e13e8e..4938141d6 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -1,40 +1,27 @@ -{-# LANGUAGE NoImplicitPrelude #-} -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE TemplateHaskell, QuasiQuotes #-} -{-# LANGUAGE MultiParamTypeClasses #-} -{-# LANGUAGE TypeFamilies #-} -{-# LANGUAGE ViewPatterns #-} -{-# LANGUAGE LambdaCase #-} -{-# LANGUAGE PatternSynonyms #-} -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE RecordWildCards #-} -{-# LANGUAGE NamedFieldPuns #-} -{-# LANGUAGE PatternGuards, MultiWayIf #-} -{-# LANGUAGE TupleSections #-} -{-# LANGUAGE FlexibleInstances, UndecidableInstances, FlexibleContexts #-} +{-# LANGUAGE UndecidableInstances #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# OPTIONS_GHC -fno-warn-orphans #-} -- MonadCrypto module Foundation where import Import.NoFoundation import Database.Persist.Sql (ConnectionPool, runSqlPool) import Text.Hamlet (hamletFile) -import Text.Jasmine (minifym) --- Used only when in "auth-dummy-login" setting is enabled. +import qualified Web.ClientSession as ClientSession + import Yesod.Auth.Message -import Yesod.Auth.Dummy import Auth.LDAP import Auth.PWHash import Auth.Dummy +import Jobs.Types -import qualified Network.Wai as W (requestMethod, pathInfo) +import qualified Network.Wai as W (pathInfo) -import Yesod.Default.Util (addStaticContentExternal) import Yesod.Core.Types (Logger) import qualified Yesod.Core.Unsafe as Unsafe import Data.CaseInsensitive (CI) import qualified Data.CaseInsensitive as CI -import qualified Data.Text.Encoding as TE import qualified Data.CryptoID as E @@ -50,8 +37,6 @@ import qualified Data.ByteString.Lazy as Lazy.ByteString import qualified Data.Text as Text import qualified Data.Text.Encoding as Text -import Data.List (foldr1) -import qualified Data.List as List import Data.Set (Set) import qualified Data.Set as Set import Data.Map (Map, (!?)) @@ -59,6 +44,7 @@ import qualified Data.Map as Map import Data.Monoid (Any(..)) +import Data.Pool import Data.Conduit (($$)) import Data.Conduit.List (sourceList) @@ -67,26 +53,31 @@ import qualified Database.Esqueleto as E import Control.Monad.Except (MonadError(..), runExceptT) import Control.Monad.Trans.Maybe (MaybeT(..)) -import Control.Monad.Trans.Reader (runReader) -import Control.Monad.Trans.Writer (WriterT(..)) +import Control.Monad.Trans.Reader (runReader, mapReaderT) +import Control.Monad.Trans.Writer (WriterT(..), runWriterT) import Control.Monad.Writer.Class (MonadWriter(..)) -import Control.Monad.Catch (handleAll) +import Control.Monad.Memo (MemoT, startEvalMemoT, MonadMemo(..)) import qualified Control.Monad.Catch as C -import System.FilePath - import Handler.Utils.StudyFeatures -import Control.Lens -import Utils -import Utils.Form +import Handler.Utils.Templates import Utils.Lens - -import Data.Aeson hiding (Error) -import Data.Aeson.TH -import qualified Data.Yaml as Yaml +import Utils.Form +import Utils.Sheet +import Utils.SystemMessage import Text.Shakespeare.Text (st) +import Yesod.Form.I18n.German +import qualified Yesod.Auth.Message as Auth + +import qualified Data.Conduit.List as C + +import qualified Crypto.Saltine.Core.SecretBox as SecretBox + +import qualified Database.Memcached.Binary.IO as Memcached +import Data.Bits (Bits(zeroBits)) + instance DisplayAble b => DisplayAble (E.CryptoID a b) where display = display . ciphertext @@ -108,14 +99,25 @@ instance DisplayAble SchoolId where -- starts running, such as database connections. Every handler will have -- access to the data present here. data UniWorX = UniWorX - { appSettings :: AppSettings - , appStatic :: Static -- ^ Settings for static file serving. - , appConnPool :: ConnectionPool -- ^ Database connection pool. - , appHttpManager :: Manager - , appLogger :: Logger - , appCryptoIDKey :: CryptoIDKey + { appSettings :: AppSettings + , appStatic :: EmbeddedStatic -- ^ Settings for static file serving. + , appConnPool :: ConnectionPool -- ^ Database connection pool. + , appSmtpPool :: Maybe SMTPPool + , appLdapPool :: Maybe LdapPool + , appWidgetMemcached :: Maybe Memcached.Connection + , appHttpManager :: Manager + , appLogger :: (ReleaseKey, TVar Logger) + , appLogSettings :: TVar LogSettings + , appCryptoIDKey :: CryptoIDKey + , appInstanceID :: InstanceId + , appJobCtl :: TVar (Map ThreadId (TMChan JobCtl)) + , appCronThread :: TMVar (ReleaseKey, ThreadId) + , appSessionKey :: ClientSession.Key + , appSecretBoxKey :: SecretBox.Key } +type SMTPPool = Pool SMTPConnection + -- This is where we define all of the routes in our application. For a full -- explanation of the syntax, please see: -- http://www.yesodweb.com/book/routing-and-handlers @@ -134,45 +136,38 @@ mkYesodData "UniWorX" $(parseRoutesFile "routes") type DB a = YesodDB UniWorX a type Form x = Html -> MForm (HandlerT UniWorX IO) (FormResult x, Widget) type MsgRenderer = MsgRendererS UniWorX -- see Utils +type MailM a = MailT (HandlerT UniWorX IO) a -- Pattern Synonyms for convenience +pattern CSheetR :: TermId -> SchoolId -> CourseShorthand -> SheetName -> SheetR -> Route UniWorX pattern CSheetR tid ssh csh shn ptn = CourseR tid ssh csh (SheetR shn ptn) +pattern CSubmissionR :: TermId -> SchoolId -> CourseShorthand -> SheetName -> CryptoFileNameSubmission -> SubmissionR -> Route UniWorX pattern CSubmissionR tid ssh csh shn cid ptn = CSheetR tid ssh csh shn (SubmissionR cid ptn) --- Menus and Favourites -data MenuItem = MenuItem - { menuItemLabel :: Text - , menuItemIcon :: Maybe Text - , menuItemRoute :: Route UniWorX - , menuItemAccessCallback' :: Handler Bool -- Check whether action is shown in ADDITION to authorization (which is always checked) - } -menuItemAccessCallback :: MenuItem -> Handler Bool -menuItemAccessCallback MenuItem{..} = (&&) <$> ((==) Authorized <$> authCheck) <*> menuItemAccessCallback' - where - authCheck = handleAny (\_ -> return . Unauthorized $ error "authCheck caught exception") $ isAuthorized menuItemRoute False +pluralDE :: (Eq a, Num a) + => a -- ^ Count + -> Text -- ^ Singular + -> Text -- ^ Plural + -> Text +pluralDE num singularForm pluralForm + | num == 1 = singularForm + | otherwise = pluralForm -data MenuTypes -- Semantische Rolle: - = NavbarAside { menuItem :: MenuItem } -- TODO - | NavbarExtra { menuItem :: MenuItem } -- TODO - | NavbarRight { menuItem :: MenuItem } -- Generell, nahezu immer sichtbar - | NavbarSecondary { menuItem :: MenuItem } -- Generell, nahezu immer sichtbar - | PageActionPrime { menuItem :: MenuItem } -- Seitenspezifische Aktion, häufig - | PageActionSecondary { menuItem :: MenuItem } -- Seitenspezifische Aktion, selten (nicht im MouseOver enthalten, immer hinten gelistet) - --- Messages +-- Messages creates type UniWorXMessage and RenderMessage UniWorX instance mkMessage "UniWorX" "messages/uniworx" "de" mkMessageVariant "UniWorX" "Campus" "messages/campus" "de" mkMessageVariant "UniWorX" "Dummy" "messages/dummy" "de" mkMessageVariant "UniWorX" "PWHash" "messages/pw-hash" "de" +mkMessageVariant "UniWorX" "Button" "messages/button" "de" -- This instance is required to use forms. You can modify renderMessage to -- achieve customized and internationalized form validation messages. instance RenderMessage UniWorX FormMessage where - renderMessage _ _ = defaultFormMessage + renderMessage _ _ = germanFormMessage -- TODO instance RenderMessage UniWorX TermIdentifier where renderMessage foundation ls TermIdentifier{..} = case season of @@ -180,15 +175,8 @@ instance RenderMessage UniWorX TermIdentifier where Winter -> renderMessage' $ MsgWinterTerm year where renderMessage' = renderMessage foundation ls -instance RenderMessage UniWorX StudyFieldType where - renderMessage foundation ls = \case - FieldPrimary -> renderMessage' MsgFieldPrimary - FieldSecondary -> renderMessage' MsgFieldSecondary - where renderMessage' = renderMessage foundation ls - newtype ShortTermIdentifier = ShortTermIdentifier TermIdentifier deriving (Eq, Ord, Read, Show) - instance RenderMessage UniWorX ShortTermIdentifier where renderMessage foundation ls (ShortTermIdentifier TermIdentifier{..}) = case season of Summer -> renderMessage' $ MsgSummerTermShort year @@ -198,41 +186,125 @@ instance RenderMessage UniWorX ShortTermIdentifier where instance RenderMessage UniWorX String where renderMessage f ls str = renderMessage f ls $ Text.pack str -instance RenderMessage UniWorX SheetFileType where - renderMessage foundation ls = \case - SheetExercise -> renderMessage' MsgSheetExercise - SheetHint -> renderMessage' MsgSheetHint - SheetSolution -> renderMessage' MsgSheetSolution - SheetMarking -> renderMessage' MsgSheetMarking - where renderMessage' = renderMessage foundation ls - -instance RenderMessage UniWorX CorrectorState where - renderMessage foundation ls = \case - CorrectorNormal -> renderMessage' MsgCorrectorNormal - CorrectorMissing -> renderMessage' MsgCorrectorMissing - CorrectorExcused -> renderMessage' MsgCorrectorExcused - where renderMessage' = renderMessage foundation ls - - instance RenderMessage UniWorX Load where - renderMessage foundation ls = \case - (Load {byTutorial=Nothing , byProportion=p}) -> renderMessage' $ MsgCorByProportionOnly p - (Load {byTutorial=Just True , byProportion=p}) -> renderMessage' $ MsgCorByProportionIncludingTutorial p - (Load {byTutorial=Just False, byProportion=p}) -> renderMessage' $ MsgCorByProportionExcludingTutorial p - where renderMessage' = renderMessage foundation ls + renderMessage foundation ls = renderMessage foundation ls . \case + (Load {byTutorial=Nothing , byProportion=p}) -> MsgCorByProportionOnly p + (Load {byTutorial=Just True , byProportion=p}) -> MsgCorByProportionIncludingTutorial p + (Load {byTutorial=Just False, byProportion=p}) -> MsgCorByProportionExcludingTutorial p +newtype MsgLanguage = MsgLanguage Lang + deriving (Eq, Ord, Show, Read) +instance RenderMessage UniWorX MsgLanguage where + renderMessage foundation ls (MsgLanguage lang@(Text.splitOn "-" -> lang')) + | ["de", "DE"] <- lang' = mr MsgGermanGermany + | ("de" : _) <- lang' = mr MsgGerman + | otherwise = lang + where + mr = renderMessage foundation ls instance RenderMessage UniWorX (UnsupportedAuthPredicate (Route UniWorX)) where renderMessage f ls (UnsupportedAuthPredicate tag route) = renderMessage f ls $ MsgUnsupportedAuthPredicate tag (show route) +embedRenderMessage ''UniWorX ''MessageClass ("Message" <>) +embedRenderMessage ''UniWorX ''NotificationTrigger $ ("NotificationTrigger" <>) . concat . drop 1 . splitCamel +embedRenderMessage ''UniWorX ''StudyFieldType id +embedRenderMessage ''UniWorX ''SheetFileType id +embedRenderMessage ''UniWorX ''CorrectorState id +embedRenderMessage ''UniWorX ''RatingException id +embedRenderMessage ''UniWorX ''SubmissionSinkException ("SubmissionSinkException" <>) +embedRenderMessage ''UniWorX ''SheetGrading ("SheetGrading" <>) +embedRenderMessage ''UniWorX ''AuthTag $ ("AuthTag" <>) . concat . drop 1 . splitCamel +embedRenderMessage ''UniWorX ''SheetSubmissionMode ("Sheet" <>) +embedRenderMessage ''UniWorX ''EncodedSecretBoxException id -data instance ButtonCssClass UniWorX = BCDefault | BCPrimary | BCSuccess | BCInfo | BCWarning | BCDanger | BCLink - deriving (Enum, Eq, Ord, Bounded, Read, Show) +newtype SheetTypeHeader = SheetTypeHeader SheetType +embedRenderMessageVariant ''UniWorX ''SheetTypeHeader ("SheetType" <>) -instance Button UniWorX SubmitButton where - label BtnSubmit = [whamlet|_{MsgBtnSubmit}|] +instance RenderMessage UniWorX UploadMode where + renderMessage foundation ls uploadMode = case uploadMode of + NoUpload -> mr MsgUploadModeNone + Upload False -> mr MsgUploadModeNoUnpack + Upload True -> mr MsgUploadModeUnpack + where + mr = renderMessage foundation ls - cssClass BtnSubmit = BCPrimary +instance RenderMessage UniWorX SheetType where + renderMessage foundation ls sheetType = case sheetType of + NotGraded -> mr $ SheetTypeHeader NotGraded + other -> mr (grading other) <> ", " <> mr (SheetTypeHeader other) + where + mr :: RenderMessage UniWorX msg => msg -> Text + mr = renderMessage foundation ls + +newtype ErrorResponseTitle = ErrorResponseTitle ErrorResponse +embedRenderMessageVariant ''UniWorX ''ErrorResponseTitle ("ErrorResponseTitle" <>) + +newtype UniWorXMessages = UniWorXMessages [SomeMessage UniWorX] + deriving (Generic, Typeable) + deriving newtype (Semigroup, Monoid, IsList) + +instance RenderMessage UniWorX UniWorXMessages where + renderMessage foundation ls (UniWorXMessages msgs) = + intercalate " " $ map (renderMessage foundation ls) msgs + +uniworxMessages :: [UniWorXMessage] -> UniWorXMessages +uniworxMessages = UniWorXMessages . map SomeMessage + +-- Menus and Favourites +data MenuType = NavbarAside | NavbarRight | NavbarSecondary | PageActionPrime | PageActionSecondary | Footer + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) + +instance Universe MenuType +instance Finite MenuType + +makePrisms ''MenuType + +data MenuItem = MenuItem + { menuItemLabel :: UniWorXMessage + , menuItemIcon :: Maybe Text -- currently from: https://fontawesome.com/icons?d=gallery + , menuItemRoute :: SomeRoute UniWorX + , menuItemAccessCallback' :: Handler Bool -- Check whether action is shown in ADDITION to authorization (which is always checked) + , menuItemModal :: Bool + , menuItemType :: MenuType + } + +makeLenses_ ''MenuItem + +instance RedirectUrl UniWorX MenuItem where + toTextUrl MenuItem{..} = toTextUrl menuItemRoute +instance HasRoute UniWorX MenuItem where + urlRoute MenuItem{..} = urlRoute menuItemRoute + +menuItemAccessCallback :: MenuItem -> Handler Bool +menuItemAccessCallback MenuItem{..} = and2M ((==) Authorized <$> authCheck) menuItemAccessCallback' + where + authCheck = handleAny (\_ -> return . Unauthorized $ error "authCheck caught exception") $ isAuthorized (urlRoute menuItemRoute) False + +$(return []) + + +data instance ButtonClass UniWorX + = BCIsButton + | BCDefault + | BCPrimary + | BCSuccess + | BCInfo + | BCWarning + | BCDanger + | BCLink + deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable) +instance Universe (ButtonClass UniWorX) +instance Finite (ButtonClass UniWorX) + +instance PathPiece (ButtonClass UniWorX) where + toPathPiece BCIsButton = "btn" + toPathPiece bClass = ("btn-" <>) . camelToPathPiece' 1 $ tshow bClass + fromPathPiece = finiteFromPathPiece + + +embedRenderMessage ''UniWorX ''ButtonSubmit id +instance Button UniWorX ButtonSubmit where + btnClasses BtnSubmit = [BCIsButton, BCPrimary] getTimeLocale' :: [Lang] -> TimeLocale @@ -241,6 +313,23 @@ getTimeLocale' = $(timeLocaleMap [("de", "de_DE.utf8")]) appTZ :: TZ appTZ = $(includeSystemTZ "Europe/Berlin") +appLanguages :: NonEmpty Lang +appLanguages = "de-DE" :| [] + +appLanguagesOpts :: ( MonadHandler m + , HandlerSite m ~ UniWorX + ) => m (OptionList Lang) +-- ^ Authoritive list of supported Languages +appLanguagesOpts = do + mr <- getsYesod renderMessage + let mkOption l = Option + { optionDisplay = mr (l : filter (/= l) (optionInternalValue <$> langOptions)) (MsgLanguage l) + , optionInternalValue = l + , optionExternalValue = l + } + langOptions = map mkOption $ toList appLanguages + return $ mkOptionList langOptions + -- Access Control data AccessPredicate @@ -248,223 +337,403 @@ data AccessPredicate | APHandler (Route UniWorX -> Bool -> Handler AuthResult) | APDB (Route UniWorX -> Bool -> DB AuthResult) -orAR, andAR :: MsgRenderer -> AuthResult -> AuthResult -> AuthResult +class (MonadHandler m, HandlerSite m ~ UniWorX) => MonadAP m where + evalAccessPred :: AccessPredicate -> Route UniWorX -> Bool -> m AuthResult + +instance {-# INCOHERENT #-} (MonadHandler m, HandlerSite m ~ UniWorX) => MonadAP m where + evalAccessPred aPred r w = liftHandlerT $ case aPred of + (APPure p) -> runReader (p r w) <$> getMsgRenderer + (APHandler p) -> p r w + (APDB p) -> runDB $ p r w + +instance (MonadHandler m, HandlerSite m ~ UniWorX, backend ~ YesodPersistBackend UniWorX) => MonadAP (ReaderT backend m) where + evalAccessPred aPred r w = mapReaderT liftHandlerT $ case aPred of + (APPure p) -> lift $ runReader (p r w) <$> getMsgRenderer + (APHandler p) -> lift $ p r w + (APDB p) -> p r w + + +orAR, andAR :: MsgRenderer -> AuthResult -> AuthResult -> AuthResult orAR _ Authorized _ = Authorized orAR _ _ Authorized = Authorized orAR _ AuthenticationRequired _ = AuthenticationRequired orAR _ _ AuthenticationRequired = AuthenticationRequired -orAR mr (Unauthorized x) (Unauthorized y) = Unauthorized . render mr $ MsgUnauthorizedOr x y +orAR mr (Unauthorized x) (Unauthorized y) = Unauthorized . render mr $ MsgUnauthorizedOr x y -- and -andAR mr (Unauthorized x) (Unauthorized y) = Unauthorized . render mr $ MsgUnauthorizedAnd x y -andAR _ reason@(Unauthorized x) _ = reason -andAR _ _ reason@(Unauthorized x) = reason +andAR mr (Unauthorized x) (Unauthorized y) = Unauthorized . render mr $ MsgUnauthorizedAnd x y +andAR _ reason@(Unauthorized _) _ = reason +andAR _ _ reason@(Unauthorized _) = reason andAR _ Authorized other = other andAR _ AuthenticationRequired _ = AuthenticationRequired -orAP,andAP :: AccessPredicate -> AccessPredicate -> AccessPredicate -orAP = liftAR orAR (== Authorized) -andAP = liftAR andAR (const False) +trueAR, falseAR :: MsgRendererS UniWorX -> AuthResult +trueAR = const Authorized +falseAR = Unauthorized . ($ MsgUnauthorized) . render -liftAR :: (MsgRenderer -> AuthResult -> AuthResult -> AuthResult) - -> (AuthResult -> Bool) -- ^ Predicate to Short-Circuit on first argument - -> AccessPredicate -> AccessPredicate -> AccessPredicate --- Ensure to first evaluate Pure conditions, then Handler before DB -liftAR ops sc (APPure f) (APPure g) = APPure $ \r w -> shortCircuitM sc (f r w) (g r w) . ops =<< ask -liftAR ops sc (APHandler f) (APHandler g) = APHandler $ \r w -> shortCircuitM sc (f r w) (g r w) . ops =<< getMsgRenderer -liftAR ops sc (APDB f) (APDB g) = APDB $ \r w -> shortCircuitM sc (f r w) (g r w) . ops =<< getMsgRenderer -liftAR ops sc (APPure f) apg = liftAR ops sc (APHandler $ \r w -> runReader (f r w) <$> getMsgRenderer) apg -liftAR ops sc apf apg@(APPure _) = liftAR ops sc apg apf -liftAR ops sc (APHandler f) apdb = liftAR ops sc (APDB $ \r w -> lift $ f r w) apdb -liftAR ops sc apdb apg@(APHandler _) = liftAR ops sc apg apdb +trueAP, falseAP :: AccessPredicate +trueAP = APPure . const . const $ trueAR <$> ask +falseAP = APPure . const . const $ falseAR <$> ask -- included for completeness -trueAP,falseAP :: AccessPredicate -trueAP = APPure . const . const $ return Authorized -falseAP = APPure . const . const $ Unauthorized . ($ MsgUnauthorized) . render <$> ask -- always use adminAP instead +tagAccessPredicate :: AuthTag -> AccessPredicate +tagAccessPredicate AuthFree = trueAP +tagAccessPredicate AuthAdmin = APDB $ \route _ -> case route of + -- Courses: access only to school admins + CourseR tid ssh csh _ -> exceptT return return $ do + authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + [E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` userAdmin) -> do + E.on $ course E.^. CourseSchool E.==. userAdmin E.^. UserAdminSchool + E.where_ $ userAdmin E.^. UserAdminUser E.==. E.val authId + E.&&. course E.^. CourseTerm E.==. E.val tid + E.&&. course E.^. CourseSchool E.==. E.val ssh + E.&&. course E.^. CourseShorthand E.==. E.val csh + return (E.countRows :: E.SqlExpr (E.Value Int64)) + guardMExceptT (c > 0) (unauthorizedI MsgUnauthorizedSchoolAdmin) + return Authorized + -- other routes: access to any admin is granted here + _other -> exceptT return return $ do + authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + adrights <- lift $ selectFirst [UserAdminUser ==. authId] [] + guardMExceptT (isJust adrights) (unauthorizedI MsgUnauthorizedSiteAdmin) + return Authorized +tagAccessPredicate AuthNoEscalation = APDB $ \route _ -> case route of + AdminHijackUserR cID -> exceptT return return $ do + myUid <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + uid <- decrypt cID + otherSchoolsAdmin <- lift $ Set.fromList . map (userAdminSchool . entityVal) <$> selectList [UserAdminUser ==. uid] [] + otherSchoolsLecturer <- lift $ Set.fromList . map (userLecturerSchool . entityVal) <$> selectList [UserLecturerUser ==. uid] [] + mySchools <- lift $ Set.fromList . map (userAdminSchool . entityVal) <$> selectList [UserAdminUser ==. myUid] [] + guardMExceptT ((otherSchoolsAdmin `Set.union` otherSchoolsLecturer) `Set.isSubsetOf` mySchools) (unauthorizedI MsgUnauthorizedAdminEscalation) + return Authorized + r -> $unsupportedAuthPredicate AuthNoEscalation r +tagAccessPredicate AuthDeprecated = APHandler $ \r _ -> do + $logWarnS "AccessControl" ("deprecated route: " <> tshow r) + addMessageI Error MsgDeprecatedRoute + allow <- appAllowDeprecated . appSettings <$> getYesod + return $ bool (Unauthorized "Deprecated Route") Authorized allow +tagAccessPredicate AuthDevelopment = APHandler $ \r _ -> do + $logWarnS "AccessControl" ("route in development: " <> tshow r) +#ifdef DEVELOPMENT + return Authorized +#else + return $ Unauthorized "Route under development" +#endif +tagAccessPredicate AuthLecturer = APDB $ \route _ -> case route of + CourseR tid ssh csh _ -> exceptT return return $ do + authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + [E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` lecturer) -> do + E.on $ course E.^. CourseId E.==. lecturer E.^. LecturerCourse + E.where_ $ lecturer E.^. LecturerUser E.==. E.val authId + E.&&. course E.^. CourseTerm E.==. E.val tid + E.&&. course E.^. CourseSchool E.==. E.val ssh + E.&&. course E.^. CourseShorthand E.==. E.val csh + return (E.countRows :: E.SqlExpr (E.Value Int64)) + guardMExceptT (c>0) (unauthorizedI MsgUnauthorizedLecturer) + return Authorized + _ -> exceptT return return $ do + authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + void . maybeMExceptT (unauthorizedI MsgUnauthorizedSchoolLecturer) $ selectFirst [UserLecturerUser ==. authId] [] + return Authorized +tagAccessPredicate AuthCorrector = APDB $ \route _ -> exceptT return return $ do + authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + resList <- lift . E.select . E.from $ \(course `E.InnerJoin` sheet `E.InnerJoin` sheetCorrector) -> do + E.on $ sheetCorrector E.^. SheetCorrectorSheet E.==. sheet E.^. SheetId + E.on $ sheet E.^. SheetCourse E.==. course E.^. CourseId + E.where_ $ sheetCorrector E.^. SheetCorrectorUser E.==. E.val authId + return (course E.^. CourseId, sheet E.^. SheetId) + let + resMap :: Map CourseId (Set SheetId) + resMap = Map.fromListWith Set.union [ (cid, Set.singleton sid) | (E.Value cid, E.Value sid) <- resList ] + case route of + CSubmissionR _ _ _ _ cID _ -> maybeT (unauthorizedI MsgUnauthorizedSubmissionCorrector) $ do + sid <- catchIfMaybeT (const True :: CryptoIDError -> Bool) $ decrypt cID + Submission{..} <- MaybeT . lift $ get sid + guard $ maybe False (== authId) submissionRatingBy + return Authorized + CSheetR tid ssh csh shn _ -> maybeT (unauthorizedI MsgUnauthorizedSheetCorrector) $ do + Entity cid _ <- MaybeT . lift . getBy $ TermSchoolCourseShort tid ssh csh + Entity sid _ <- MaybeT . lift . getBy $ CourseSheet cid shn + guard $ sid `Set.member` fromMaybe Set.empty (resMap !? cid) + return Authorized + CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgUnauthorizedCorrector) $ do + Entity cid _ <- MaybeT . lift . getBy $ TermSchoolCourseShort tid ssh csh + guard $ cid `Set.member` Map.keysSet resMap + return Authorized + _ -> do + guardMExceptT (not $ Map.null resMap) (unauthorizedI MsgUnauthorizedCorrectorAny) + return Authorized +tagAccessPredicate AuthTime = APDB $ \route _ -> case route of + CSheetR tid ssh csh shn subRoute -> maybeT (unauthorizedI MsgUnauthorizedSheetTime) $ do + Entity cid _ <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh + Entity _sid Sheet{..} <- MaybeT . getBy $ CourseSheet cid shn + cTime <- liftIO getCurrentTime + let + visible = NTop sheetVisibleFrom <= NTop (Just cTime) + active = sheetActiveFrom <= cTime && cTime <= sheetActiveTo + marking = cTime > sheetActiveTo + + guard visible + + case subRoute of + SFileR SheetExercise _ -> guard $ sheetActiveFrom <= cTime + SFileR SheetHint _ -> guard $ maybe False (<= cTime) sheetHintFrom + SFileR SheetSolution _ -> guard $ maybe False (<= cTime) sheetSolutionFrom + SubmissionNewR -> guard active + SubmissionR _ SAssignR -> guard marking -- Correctors can only be assigned when the Sheet is inactive, since submissions are subject to change + SubmissionR _ _ -> guard active + _ -> return () + + return Authorized + + CourseR tid ssh csh CRegisterR -> do + now <- liftIO getCurrentTime + mbc <- getBy $ TermSchoolCourseShort tid ssh csh + mAid <- lift maybeAuthId + registered <- case (mbc,mAid) of + (Just (Entity cid _), Just uid) -> isJust <$> (getBy $ UniqueParticipant uid cid) + _ -> return False + case mbc of + (Just (Entity _ Course{courseRegisterFrom, courseRegisterTo})) + | not registered + , maybe False (now >=) courseRegisterFrom -- Nothing => no registration allowed + , maybe True (now <=) courseRegisterTo -> return Authorized + (Just (Entity _ Course{courseDeregisterUntil})) + | registered + , maybe True (now <=) courseDeregisterUntil -> return Authorized + _other -> unauthorizedI MsgUnauthorizedCourseTime + + MessageR cID -> maybeT (unauthorizedI MsgUnauthorizedSystemMessageTime) $ do + smId <- decrypt cID + SystemMessage{systemMessageFrom, systemMessageTo} <- MaybeT $ get smId + cTime <- (NTop . Just) <$> liftIO getCurrentTime + guard $ NTop systemMessageFrom <= cTime + && NTop systemMessageTo >= cTime + return Authorized + + r -> $unsupportedAuthPredicate AuthTime r +tagAccessPredicate AuthRegistered = APDB $ \route _ -> case route of + CourseR tid ssh csh _ -> exceptT return return $ do + authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + [E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` courseParticipant) -> do + E.on $ course E.^. CourseId E.==. courseParticipant E.^. CourseParticipantCourse + E.where_ $ courseParticipant E.^. CourseParticipantUser E.==. E.val authId + E.&&. course E.^. CourseTerm E.==. E.val tid + E.&&. course E.^. CourseSchool E.==. E.val ssh + E.&&. course E.^. CourseShorthand E.==. E.val csh + return (E.countRows :: E.SqlExpr (E.Value Int64)) + guardMExceptT (c > 0) (unauthorizedI MsgUnauthorizedRegistered) + return Authorized + r -> $unsupportedAuthPredicate AuthRegistered r +tagAccessPredicate AuthParticipant = APDB $ \route _ -> case route of + CourseR tid ssh csh (CUserR cID) -> exceptT return return $ do + let authorizedIfExists f = do + [E.Value ok] <- lift . E.select . return . E.exists $ E.from f + whenExceptT ok Authorized + participant <- decrypt cID + -- participant is currently registered + authorizedIfExists $ \(course `E.InnerJoin` courseParticipant) -> do + E.on $ course E.^. CourseId E.==. courseParticipant E.^. CourseParticipantCourse + E.where_ $ courseParticipant E.^. CourseParticipantUser E.==. E.val participant + E.&&. course E.^. CourseTerm E.==. E.val tid + E.&&. course E.^. CourseSchool E.==. E.val ssh + E.&&. course E.^. CourseShorthand E.==. E.val csh + -- participant has at least one submission + authorizedIfExists $ \(course `E.InnerJoin` sheet `E.InnerJoin` submission `E.InnerJoin` submissionUser) -> do + E.on $ submission E.^. SubmissionId E.==. submissionUser E.^. SubmissionUserSubmission + E.on $ sheet E.^. SheetId E.==. submission E.^. SubmissionSheet + E.on $ course E.^. CourseId E.==. sheet E.^. SheetCourse + E.where_ $ submissionUser E.^. SubmissionUserUser E.==. E.val participant + E.&&. course E.^. CourseTerm E.==. E.val tid + E.&&. course E.^. CourseSchool E.==. E.val ssh + E.&&. course E.^. CourseShorthand E.==. E.val csh + -- participant is member of a submissionGroup + authorizedIfExists $ \(course `E.InnerJoin` submissionGroup `E.InnerJoin` submissionGroupUser) -> do + E.on $ submissionGroup E.^. SubmissionGroupId E.==. submissionGroupUser E.^. SubmissionGroupUserSubmissionGroup + E.on $ course E.^. CourseId E.==. submissionGroup E.^. SubmissionGroupCourse + E.where_ $ submissionGroupUser E.^. SubmissionGroupUserUser E.==. E.val participant + E.&&. course E.^. CourseTerm E.==. E.val tid + E.&&. course E.^. CourseSchool E.==. E.val ssh + E.&&. course E.^. CourseShorthand E.==. E.val csh + -- participant is a sheet corrector + authorizedIfExists $ \(course `E.InnerJoin` sheet `E.InnerJoin` sheetCorrector) -> do + E.on $ sheet E.^. SheetId E.==. sheetCorrector E.^. SheetCorrectorSheet + E.on $ course E.^. CourseId E.==. sheet E.^. SheetCourse + E.where_ $ sheetCorrector E.^. SheetCorrectorUser E.==. E.val participant + E.&&. course E.^. CourseTerm E.==. E.val tid + E.&&. course E.^. CourseSchool E.==. E.val ssh + E.&&. course E.^. CourseShorthand E.==. E.val csh + -- participant is a tutorial user + authorizedIfExists $ \(course `E.InnerJoin` tutorial `E.InnerJoin` tutorialUser) -> do + E.on $ tutorial E.^. TutorialId E.==. tutorialUser E.^. TutorialUserTutorial + E.on $ course E.^. CourseId E.==. tutorial E.^. TutorialCourse + E.where_ $ tutorialUser E.^. TutorialUserUser E.==. E.val participant + E.&&. course E.^. CourseTerm E.==. E.val tid + E.&&. course E.^. CourseSchool E.==. E.val ssh + E.&&. course E.^. CourseShorthand E.==. E.val csh + -- participant is tutor for this course + authorizedIfExists $ \(course `E.InnerJoin` tutorial) -> do + E.on $ course E.^. CourseId E.==. tutorial E.^. TutorialCourse + E.where_ $ tutorial E.^. TutorialTutor E.==. E.val participant + E.&&. course E.^. CourseTerm E.==. E.val tid + E.&&. course E.^. CourseSchool E.==. E.val ssh + E.&&. course E.^. CourseShorthand E.==. E.val csh + -- participant is lecturer for this course + authorizedIfExists $ \(course `E.InnerJoin` lecturer) -> do + E.on $ course E.^. CourseId E.==. lecturer E.^. LecturerCourse + E.where_ $ lecturer E.^. LecturerUser E.==. E.val participant + E.&&. course E.^. CourseTerm E.==. E.val tid + E.&&. course E.^. CourseSchool E.==. E.val ssh + E.&&. course E.^. CourseShorthand E.==. E.val csh + unauthorizedI MsgUnauthorizedParticipant + r -> $unsupportedAuthPredicate AuthParticipant r +tagAccessPredicate AuthCapacity = APDB $ \route _ -> case route of + CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgCourseNoCapacity) $ do + Entity cid Course{..} <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh + registered <- lift $ fromIntegral <$> count [ CourseParticipantCourse ==. cid ] + guard $ NTop courseCapacity > NTop (Just registered) + return Authorized + r -> $unsupportedAuthPredicate AuthCapacity r +tagAccessPredicate AuthEmpty = APDB $ \route _ -> case route of + CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgCourseNotEmpty) $ do + -- Entity cid Course{..} <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh + cid <- MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh + assertM_ (<= 0) . lift $ count [ CourseParticipantCourse ==. cid ] + assertM_ ((<= 0) :: Int -> Bool) . lift . fmap (E.unValue . unsafeHead) $ E.select . E.from $ \(sheet `E.InnerJoin` submission) -> do + E.on $ sheet E.^. SheetId E.==. submission E.^. SubmissionSheet + E.where_ $ sheet E.^. SheetCourse E.==. E.val cid + return E.countRows + return Authorized + r -> $unsupportedAuthPredicate AuthEmpty r +tagAccessPredicate AuthMaterials = APDB $ \route _ -> case route of + CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgUnfreeMaterials) $ do + Entity _ Course{..} <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh + guard courseMaterialFree + return Authorized + r -> $unsupportedAuthPredicate AuthMaterials r +tagAccessPredicate AuthOwner = APDB $ \route _ -> case route of + CSubmissionR _ _ _ _ cID _ -> exceptT return return $ do + sid <- catchIfMExceptT (const $ unauthorizedI MsgUnauthorizedSubmissionOwner) (const True :: CryptoIDError -> Bool) $ decrypt cID + authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId + void . maybeMExceptT (unauthorizedI MsgUnauthorizedSubmissionOwner) . getBy $ UniqueSubmissionUser authId sid + return Authorized + r -> $unsupportedAuthPredicate AuthOwner r +tagAccessPredicate AuthRated = APDB $ \route _ -> case route of + CSubmissionR _ _ _ _ cID _ -> maybeT (unauthorizedI MsgUnauthorizedSubmissionRated) $ do + sid <- catchIfMaybeT (const True :: CryptoIDError -> Bool) $ decrypt cID + sub <- MaybeT $ get sid + guard $ submissionRatingDone sub + return Authorized + r -> $unsupportedAuthPredicate AuthRated r +tagAccessPredicate AuthUserSubmissions = APDB $ \route _ -> case route of + CSheetR tid ssh csh shn _ -> maybeT (unauthorizedI MsgUnauthorizedUserSubmission) $ do + Entity cid _ <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh + Entity _ Sheet{sheetSubmissionMode} <- MaybeT . getBy $ CourseSheet cid shn + guard $ sheetSubmissionMode == UserSubmissions + return Authorized + r -> $unsupportedAuthPredicate AuthUserSubmissions r +tagAccessPredicate AuthCorrectorSubmissions = APDB $ \route _ -> case route of + CSheetR tid ssh csh shn _ -> maybeT (unauthorizedI MsgUnauthorizedCorrectorSubmission) $ do + Entity cid _ <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh + Entity _ Sheet{sheetSubmissionMode} <- MaybeT . getBy $ CourseSheet cid shn + guard $ sheetSubmissionMode == CorrectorSubmissions + return Authorized + r -> $unsupportedAuthPredicate AuthCorrectorSubmissions r +tagAccessPredicate AuthAuthentication = APDB $ \route _ -> case route of + MessageR cID -> maybeT (unauthorizedI MsgUnauthorizedSystemMessageAuth) $ do + smId <- decrypt cID + SystemMessage{..} <- MaybeT $ get smId + isAuthenticated <- isJust <$> liftHandlerT maybeAuthId + guard $ not systemMessageAuthenticatedOnly || isAuthenticated + return Authorized + r -> $unsupportedAuthPredicate AuthAuthentication r +tagAccessPredicate AuthRead = APHandler . const $ bool (return Authorized) (unauthorizedI MsgUnauthorizedWrite) +tagAccessPredicate AuthWrite = APHandler . const $ bool (unauthorizedI MsgUnauthorized) (return Authorized) -adminAP :: AccessPredicate -- access for admins (of appropriate school in case of course-routes) -adminAP = APDB $ \route _ -> case route of - -- Courses: access only to school admins - CourseR tid ssh csh _ -> exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId - [E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` userAdmin) -> do - E.on $ course E.^. CourseSchool E.==. userAdmin E.^. UserAdminSchool - E.where_ $ userAdmin E.^. UserAdminUser E.==. E.val authId - E.&&. course E.^. CourseTerm E.==. E.val tid - E.&&. course E.^. CourseSchool E.==. E.val ssh - E.&&. course E.^. CourseShorthand E.==. E.val csh - return (E.countRows :: E.SqlExpr (E.Value Int64)) - guardMExceptT (c > 0) (unauthorizedI MsgUnauthorizedSchoolAdmin) - return Authorized - -- other routes: access to any admin is granted here - _other -> exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId - adrights <- lift $ selectFirst [UserAdminUser ==. authId] [] - guardMExceptT (isJust adrights) (unauthorizedI $ MsgUnauthorized) - return Authorized +newtype InvalidAuthTag = InvalidAuthTag Text + deriving (Eq, Ord, Show, Read, Generic, Typeable) +instance Exception InvalidAuthTag +type DNF a = Set (NonNull (Set a)) -knownTags :: Map (CI Text) AccessPredicate -knownTags = Map.fromList -- should not throw exceptions, i.e. no getBy404 or requireAuthId - [("free", trueAP) - ,("deprecated", APHandler $ \r _ -> do - $logWarnS "AccessControl" ("deprecated route: " <> tshow r) - addMessageI Error MsgDeprecatedRoute - allow <- appAllowDeprecated . appSettings <$> getYesod - return $ bool (Unauthorized "Deprecated Route") Authorized allow - ) - ,("lecturer", APDB $ \route _ -> case route of - CourseR tid ssh csh _ -> exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId - [E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` lecturer) -> do - E.on $ course E.^. CourseId E.==. lecturer E.^. LecturerCourse - E.where_ $ lecturer E.^. LecturerUser E.==. E.val authId - E.&&. course E.^. CourseTerm E.==. E.val tid - E.&&. course E.^. CourseSchool E.==. E.val ssh - E.&&. course E.^. CourseShorthand E.==. E.val csh - return (E.countRows :: E.SqlExpr (E.Value Int64)) - guardMExceptT (c>0) (unauthorizedI MsgUnauthorizedLecturer) - return Authorized - _ -> exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId - void . maybeMExceptT (unauthorizedI MsgUnauthorizedSchoolLecturer) $ selectFirst [UserLecturerUser ==. authId] [] - return Authorized - ) - ,("corrector", APDB $ \route _ -> exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId - resList <- lift . E.select . E.from $ \(course `E.InnerJoin` sheet `E.InnerJoin` sheetCorrector) -> do - E.on $ sheetCorrector E.^. SheetCorrectorSheet E.==. sheet E.^. SheetId - E.on $ sheet E.^. SheetCourse E.==. course E.^. CourseId - E.where_ $ sheetCorrector E.^. SheetCorrectorUser E.==. E.val authId - return (course E.^. CourseId, sheet E.^. SheetId) - let - resMap :: Map CourseId (Set SheetId) - resMap = Map.fromListWith Set.union [ (cid, Set.singleton sid) | (E.Value cid, E.Value sid) <- resList ] - case route of - CSubmissionR _ _ _ _ cID _ -> maybeT (unauthorizedI MsgUnauthorizedSubmissionCorrector) $ do - sid <- catchIfMaybeT (const True :: CryptoIDError -> Bool) $ decrypt cID - Submission{..} <- MaybeT . lift $ get sid - guard $ maybe False (== authId) submissionRatingBy - return Authorized - CSheetR tid ssh csh shn _ -> maybeT (unauthorizedI MsgUnauthorizedSheetCorrector) $ do - Entity cid _ <- MaybeT . lift . getBy $ TermSchoolCourseShort tid ssh csh - Entity sid _ <- MaybeT . lift . getBy $ CourseSheet cid shn - guard $ sid `Set.member` fromMaybe Set.empty (resMap !? cid) - return Authorized - CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgUnauthorizedCorrector) $ do - Entity cid _ <- MaybeT . lift . getBy $ TermSchoolCourseShort tid ssh csh - guard $ cid `Set.member` Map.keysSet resMap - return Authorized - _ -> do - guardMExceptT (not $ Map.null resMap) (unauthorizedI MsgUnauthorizedCorrectorAny) - return Authorized - ) - ,("time", APDB $ \route _ -> case route of - CSheetR tid ssh csh shn subRoute -> maybeT (unauthorizedI MsgUnauthorizedSheetTime) $ do - Entity cid _ <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh - Entity _sid Sheet{..} <- MaybeT . getBy $ CourseSheet cid shn - cTime <- liftIO getCurrentTime - let - visible = NTop sheetVisibleFrom <= NTop (Just cTime) - active = sheetActiveFrom <= cTime && cTime <= sheetActiveTo +data SessionAuthTags = SessionActiveAuthTags | SessionInactiveAuthTags + deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic) +instance Universe SessionAuthTags +instance Finite SessionAuthTags +nullaryPathPiece ''SessionAuthTags (camelToPathPiece' 1) - guard visible - - case subRoute of - SFileR SheetExercise _ -> guard $ sheetActiveFrom <= cTime - SFileR SheetHint _ -> guard $ maybe False (<= cTime) sheetHintFrom - SFileR SheetSolution _ -> guard $ maybe False (<= cTime) sheetSolutionFrom - SubmissionNewR -> guard active - SubmissionR _ _ -> guard active - _ -> return () - - return Authorized - - CourseR tid ssh csh CRegisterR -> maybeT (unauthorizedI MsgUnauthorizedCourseTime) $ do - Entity cid Course{..} <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh - cTime <- (NTop . Just) <$> liftIO getCurrentTime - guard $ NTop courseRegisterFrom <= cTime - && NTop courseRegisterTo >= cTime - return Authorized - - r -> $unsupportedAuthPredicate "time" r - ) - ,("registered", APDB $ \route _ -> case route of - CourseR tid ssh csh _ -> exceptT return return $ do - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId - [E.Value c] <- lift . E.select . E.from $ \(course `E.InnerJoin` courseParticipant) -> do - E.on $ course E.^. CourseId E.==. courseParticipant E.^. CourseParticipantCourse - E.where_ $ courseParticipant E.^. CourseParticipantUser E.==. E.val authId - E.&&. course E.^. CourseTerm E.==. E.val tid - E.&&. course E.^. CourseSchool E.==. E.val ssh - E.&&. course E.^. CourseShorthand E.==. E.val csh - return (E.countRows :: E.SqlExpr (E.Value Int64)) - guardMExceptT (c > 0) (unauthorizedI MsgUnauthorizedParticipant) - return Authorized - r -> $unsupportedAuthPredicate "registered" r - ) - ,("capacity", APDB $ \route _ -> case route of - CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgCourseNoCapacity) $ do - Entity cid Course{..} <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh - registered <- lift $ fromIntegral <$> count [ CourseParticipantCourse ==. cid ] - guard $ NTop courseCapacity > NTop (Just registered) - return Authorized - r -> $unsupportedAuthPredicate "capacity" r - ) - ,("materials", APDB $ \route _ -> case route of - CourseR tid ssh csh _ -> maybeT (unauthorizedI MsgUnfreeMaterials) $ do - Entity _ Course{..} <- MaybeT . getBy $ TermSchoolCourseShort tid ssh csh - guard courseMaterialFree - return Authorized - r -> $unsupportedAuthPredicate "materials" r - ) - ,("owner", APDB $ \route _ -> case route of - CSubmissionR _ _ _ _ cID _ -> exceptT return return $ do - sid <- catchIfMExceptT (const $ unauthorizedI MsgUnauthorizedSubmissionOwner) (const True :: CryptoIDError -> Bool) $ decrypt cID - authId <- maybeExceptT AuthenticationRequired $ lift maybeAuthId - void . maybeMExceptT (unauthorizedI MsgUnauthorizedSubmissionOwner) . getBy $ UniqueSubmissionUser authId sid - return Authorized - r -> $unsupportedAuthPredicate "owner" r - ) - ,("rated", APDB $ \route _ -> case route of - CSubmissionR _ _ _ _ cID _ -> maybeT (unauthorizedI MsgUnauthorizedSubmissionRated) $ do - sid <- catchIfMaybeT (const True :: CryptoIDError -> Bool) $ decrypt cID - sub <- MaybeT $ get sid - guard $ submissionRatingDone sub - return Authorized - r -> $unsupportedAuthPredicate "rated" r - ) - ,("isRead", APHandler . const $ bool (return Authorized) (unauthorizedI MsgUnauthorizedWrite)) - ,("isWrite", APHandler . const $ bool (unauthorizedI MsgUnauthorized) (return Authorized)) - ] - - -tag2ap :: Text -> AccessPredicate -tag2ap t = case Map.lookup (CI.mk t) knownTags of - (Just acp) -> acp - Nothing -> APHandler $ \_route _isWrite -> do -- Can this be pure like falseAP? GK: not if we want to log a message (which we definitely should) - $logWarnS "AccessControl" $ "'" <> t <> "' not known to access control" - unauthorizedI MsgUnauthorized - -route2ap :: Route UniWorX -> AccessPredicate -route2ap r = foldr orAP adminAP attrsAND -- adminAP causes all to be in DB!!! GK: Due to shortCircuitM this (while still true) is no longer costly (we do a `runDB` but then only actually send off queries, if needed) +routeAuthTags :: Route UniWorX -> Either InvalidAuthTag (NonNull (DNF AuthTag)) +-- ^ DNF up to entailment: +-- +-- > (A_1 && A_2 && ...) OR' B OR' ... +-- +-- > A OR' B := ((A |- B) ==> A) && (A || B) +routeAuthTags = fmap (impureNonNull . Set.mapMonotonic impureNonNull) . ofoldM partition' (Set.singleton $ Set.singleton AuthAdmin) . routeAttrs where - attrsAND = map splitAND $ Set.toList $ routeAttrs r - splitAND = foldr1 andAP . map tag2ap . Text.splitOn "AND" + partition' :: Set (Set AuthTag) -> Text -> Either InvalidAuthTag (Set (Set AuthTag)) + partition' prev t + | Just (Set.fromList . toNullable -> authTags) <- fromNullable =<< mapM fromPathPiece (Text.splitOn "AND" t) + = if + | oany (authTags `Set.isSubsetOf`) prev + -> Right prev + | otherwise + -> Right . Set.insert authTags $ Set.filter (not . (`Set.isSubsetOf` authTags)) prev + | otherwise + = Left $ InvalidAuthTag t -evalAccessDB :: Route UniWorX -> Bool -> DB AuthResult -- all requests, regardless of POST/GET, use isWriteRequest otherwise -evalAccessDB r w = case route2ap r of - (APPure p) -> lift $ runReader (p r w) <$> getMsgRenderer - (APHandler p) -> lift $ p r w - (APDB p) -> p r w +evalAuthTags :: forall m. (MonadAP m, MonadLogger m) => AuthTagActive -> NonNull (DNF AuthTag) -> Route UniWorX -> Bool -> WriterT (Set AuthTag) m AuthResult +-- ^ `tell`s disabled predicates, identified as pivots +evalAuthTags AuthTagActive{..} (map (Set.toList . toNullable) . Set.toList . toNullable -> authDNF) route isWrite + = startEvalMemoT $ do + mr <- lift getMsgRenderer + let + authTagIsInactive = not . authTagIsActive -evalAccess :: Route UniWorX -> Bool -> Handler AuthResult -evalAccess r w = case route2ap r of - (APPure p) -> runReader (p r w) <$> getMsgRenderer - (APHandler p) -> p r w - (APDB p) -> runDB $ p r w + evalAuthTag :: AuthTag -> MemoT AuthTag AuthResult (WriterT (Set AuthTag) m) AuthResult + evalAuthTag = memo $ \authTag -> lift . lift $ evalAccessPred (tagAccessPredicate authTag) route isWrite + orAR', andAR' :: forall m'. Monad m' => m' AuthResult -> m' AuthResult -> m' AuthResult + orAR' = shortCircuitM (is _Authorized) (orAR mr) + andAR' = shortCircuitM (is _Unauthorized) (andAR mr) + + evalDNF :: [[AuthTag]] -> MemoT AuthTag AuthResult (WriterT (Set AuthTag) m) AuthResult + evalDNF = foldr (\ats ar -> ar `orAR'` foldr (\aTag ar' -> ar' `andAR'` evalAuthTag aTag) (return $ trueAR mr) ats) (return $ falseAR mr) + + lift . $logDebugS "evalAuthTags" . tshow . (route, isWrite, )$ map (map $ id &&& authTagIsActive) authDNF + + result <- evalDNF $ filter (all authTagIsActive) authDNF + + unless (is _Authorized result) . forM_ (filter (any authTagIsInactive) authDNF) $ \conj -> + whenM (allM conj (\aTag -> (return . not $ authTagIsActive aTag) `or2M` (not . is _Unauthorized <$> evalAuthTag aTag))) $ do + let pivots = filter authTagIsInactive conj + whenM (allM pivots $ fmap (is _Authorized) . evalAuthTag) $ do + lift $ $logDebugS "evalAuthTags" [st|Recording pivots: #{tshow pivots}|] + lift . tell $ Set.fromList pivots + + return result + +evalAccess :: (MonadLogger m, MonadHandler m, HandlerSite m ~ UniWorX) => Route UniWorX -> Bool -> m AuthResult +evalAccess route isWrite = do + tagActive <- fromMaybe def <$> lookupSessionJson SessionActiveAuthTags + dnf <- either throwM return $ routeAuthTags route + (result, deactivated) <- runWriterT $ evalAuthTags tagActive dnf route isWrite + result <$ tellSessionJson SessionInactiveAuthTags deactivated + +evalAccessDB :: (MonadLogger m, MonadHandler m, HandlerSite m ~ UniWorX) => Route UniWorX -> Bool -> ReaderT (YesodPersistBackend UniWorX) m AuthResult +evalAccessDB = evalAccess + +redirectAccess :: (MonadLogger m, MonadHandler m, HandlerSite m ~ UniWorX) => Route UniWorX -> m a +redirectAccess url = do + -- must hide URL if not authorized + access <- evalAccess url False + case access of + Authorized -> redirect url + _ -> permissionDeniedI MsgUnauthorizedRedirect + +-- | Verify that the currently logged in user is lecturer or corrector for at least one sheet for the given course +evalAccessCorrector :: (MonadLogger m, MonadHandler m, HandlerSite m ~ UniWorX) + => TermId -> SchoolId -> CourseShorthand -> m AuthResult +evalAccessCorrector tid ssh csh = evalAccess (CourseR tid ssh csh CNotesR) False -- Please see the documentation for the Yesod typeclass. There are a number @@ -479,9 +748,9 @@ instance Yesod UniWorX where -- Store session data on the client in encrypted cookies, -- default session idle timeout is 120 minutes - makeSessionBackend _ = Just <$> defaultClientSessionBackend - 120 -- timeout in minutes - "client_session_key.aes" + makeSessionBackend UniWorX{appSessionKey,appSettings=AppSettings{appSessionTimeout}} = do + (getCachedDate, _) <- clientSessionDateCacher appSessionTimeout + return . Just $ clientSessionBackend appSessionKey getCachedDate maximumContentLength _ _ = Just $ 50 * 2^20 @@ -508,7 +777,7 @@ instance Yesod UniWorX where cid <- MaybeT . getKeyBy $ TermSchoolCourseShort tid ssh csh user <- MaybeT $ get uid let courseFavourite = CourseFavourite uid now cid - + $logDebugS "updateFavourites" [st|Updating/Inserting: #{tshow courseFavourite}|] -- update Favourites void . lift $ upsertBy @@ -533,120 +802,65 @@ instance Yesod UniWorX where $logDebugS "normalizeRouteMiddleware" [st|Redirecting to #{tshow route'}|] redirectWith movedPermanently301 route' - defaultLayout widget = do - master <- getYesod - let AppSettings { appUserDefaults = UserDefaultConf{..}, .. } = appSettings master - mmsgs <- getMessages + -- Since we implement `errorHandler` ourselves we don't need `defaultMessageWidget` + defaultMessageWidget _title _body = error "defaultMessageWidget: undefined" - mcurrentRoute <- getCurrentRoute + errorHandler err = do + mr <- getMessageRender + let + encrypted :: ToJSON a => a -> Widget -> Widget + encrypted plaintextJson plaintext = do + canDecrypt <- (== Authorized) <$> evalAccess AdminErrMsgR True + shouldEncrypt <- getsYesod $ appEncryptErrors . appSettings + if + | shouldEncrypt + , not canDecrypt -> do + ciphertext <- encodedSecretBox SecretBoxPretty plaintextJson - -- Get the breadcrumbs, as defined in the YesodBreadcrumbs instance. - (title, parents) <- breadcrumbs + [whamlet| +
_{MsgErrorResponseEncrypted} +
+ #{ciphertext}
+ |]
+ | otherwise -> plaintext
--- let isParent :: Route UniWorX -> Bool
--- isParent r = r == (fst parents)
+ errPage = case err of
+ NotFound -> [whamlet|_{MsgErrorResponseNotFound}|]
+ InternalError err' -> encrypted err' [whamlet|
#{err'}|]
+ InvalidArgs errs -> [whamlet|
+
_{MsgErrorResponseNotAuthenticated}|] + PermissionDenied err' -> [whamlet|
#{err'}|] + BadMethod method -> [whamlet|
_{MsgErrorResponseBadMethod (decodeUtf8 method)}|] + fmap toTypedContent . siteLayout (Just . toHtml . mr $ ErrorResponseTitle err) $ do + toWidget + [cassius| + .errMsg + white-space: pre-wrap + font-family: monospace + |] + errPage - let menu = defaultLinks ++ maybe [] pageActions mcurrentRoute - - menuTypes <- filterM (menuItemAccessCallback . menuItem) menu - - isAuth <- isJust <$> maybeAuthId - - -- Lookup Favourites & Theme if possible -- TODO: cache this info in a cookie?! - (favourites', currentTheme) <- do - muid <- maybeAuthPair - case muid of - Nothing -> return ([],userDefaultTheme) - (Just (uid,user)) -> do - favs <- runDB $ E.select . E.from $ \(course `E.InnerJoin` courseFavourite) -> do - E.on (course E.^. CourseId E.==. courseFavourite E.^. CourseFavouriteCourse) - E.where_ (courseFavourite E.^. CourseFavouriteUser E.==. E.val uid) - E.orderBy [ E.asc $ course E.^. CourseShorthand ] - return course - return (favs, userTheme user) - favourites <- forM favourites' $ \(Entity _ c@Course{..}) - -> let - courseRoute = CourseR courseTerm courseSchool courseShorthand CShowR - in (c, courseRoute, ) <$> filterM (menuItemAccessCallback . menuItem) (pageActions courseRoute) - - let highlight :: Route UniWorX -> Bool -- highlight last route in breadcrumbs, favorites taking priority - highlight = let crumbs = mcons mcurrentRoute $ fst <$> reverse parents - navItems = map snd3 favourites ++ map (menuItemRoute . menuItem) menuTypes - highR = find (`elem` navItems) . uncurry (++) $ partition (`elem` map snd3 favourites) crumbs - in \r -> Just r == highR - favouriteTerms :: [TermIdentifier] - favouriteTerms = Set.toDescList $ foldMap (\(Course{..}, _, _) -> Set.singleton $ unTermKey courseTerm) favourites - favouriteTerm :: TermIdentifier -> [(Course, Route UniWorX, [MenuTypes])] - favouriteTerm tid = filter (\(Course{..}, _, _) -> unTermKey courseTerm == tid) favourites - - -- We break up the default layout into two components: - -- default-layout is the contents of the body tag, and - -- default-layout-wrapper is the entire page. Since the final - -- value passed to hamletToRepHtml cannot be a widget, this allows - -- you to use normal widget features in default-layout. - - - let - navbar :: Widget - navbar = $(widgetFile "widgets/navbar") - asidenav :: Widget - asidenav = $(widgetFile "widgets/asidenav") - contentHeadline :: Maybe Widget - contentHeadline = pageHeading =<< mcurrentRoute - breadcrumbs :: Widget - breadcrumbs = $(widgetFile "widgets/breadcrumbs") - pageactionprime :: Widget - pageactionprime = $(widgetFile "widgets/pageactionprime") -- TODO: rename, since it also shows secondary pageActions now - -- functions to determine if there are page-actions (primary or secondary) - isPageActionPrime :: MenuTypes -> Bool - isPageActionPrime (PageActionPrime _) = True - isPageActionPrime (PageActionSecondary _) = True - isPageActionPrime _ = False - hasPageActions :: Bool - hasPageActions = any isPageActionPrime menuTypes - - pc <- widgetToPageContent $ do - addStylesheetRemote "https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,600,800,900|Roboto:300,400,600" - addScript $ StaticR js_zepto_js - addScript $ StaticR js_fetchPolyfill_js - addScript $ StaticR js_urlPolyfill_js - addScript $ StaticR js_featureChecker_js - addScript $ StaticR js_flatpickr_js - addScript $ StaticR js_tabber_js - addStylesheet $ StaticR css_flatpickr_css - addStylesheet $ StaticR css_tabber_css - addStylesheet $ StaticR css_fonts_css - addStylesheet $ StaticR css_fontawesome_css - $(widgetFile "default-layout") - $(widgetFile "standalone/modal") - $(widgetFile "standalone/showHide") - $(widgetFile "standalone/inputs") - $(widgetFile "standalone/tooltip") - $(widgetFile "standalone/tabber") - $(widgetFile "standalone/alerts") - $(widgetFile "standalone/datepicker") - withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet") + defaultLayout = siteLayout Nothing -- The page to be redirected to when authentication is required. authRoute _ = Just $ AuthR LoginR isAuthorized = evalAccess - -- This function creates static content files in the static folder - -- and names them based on a hash of their content. This allows - -- expiration dates to be set far in the future without worry of - -- users receiving stale content. - addStaticContent ext mime content = do - master <- getYesod - let staticDir = appStaticDir $ appSettings master - addStaticContentExternal - minifym - genFileName - staticDir - (StaticR . flip StaticRoute []) - ext - mime - content + addStaticContent ext _mime content = do + UniWorX{appWidgetMemcached, appSettings} <- getYesod + for ((,) <$> appWidgetMemcached <*> appWidgetMemcachedConf appSettings) $ \(mConn, WidgetMemcachedConf{ widgetMemcachedConnectInfo = _, .. }) -> do + let expiry = (maybe 0 ceiling widgetMemcachedExpiry) + touch = liftIO $ Memcached.touch expiry (encodeUtf8 $ pack fileName) mConn + add = liftIO $ Memcached.add zeroBits expiry (encodeUtf8 $ pack fileName) content mConn + absoluteLink = unpack widgetMemcachedBaseUrl > fileName + C.catchIf Memcached.isKeyNotFound touch $ \_ -> + C.handleIf Memcached.isKeyExists (\_ -> return ()) add + return . Left $ pack absoluteLink where -- Generate a unique filename based on the content itself, this is used -- for deduplication so a collision resistant hash function is required @@ -655,20 +869,157 @@ instance Yesod UniWorX where -- -- Length of hash is 144 bits instead of MD5's 128, so as to avoid -- padding after base64-conversion - genFileName lbs = Text.unpack - . Text.decodeUtf8 - . Base64.encode - . (convert :: Digest (SHAKE256 144) -> ByteString) - . runIdentity - $ sourceList (Lazy.ByteString.toChunks lbs) $$ sinkHash + fileName = (<.> unpack ext) + . unpack + . decodeUtf8 + . Base64.encode + . (convert :: Digest (SHAKE256 144) -> ByteString) + . runIdentity + $ sourceList (Lazy.ByteString.toChunks content) $$ sinkHash -- What messages should be logged. The following includes all messages when -- in development, and warnings and errors in production. - shouldLog app _source level = appShouldLogAll (appSettings app) || level >= appMinimumLogLevel (appSettings app) + shouldLog _ _ _ = error "Must use shouldLogIO" + shouldLogIO app _source level = do + LogSettings{..} <- readTVarIO $ appLogSettings app + return $ logAll || level >= logMinimumLevel - makeLogger = return . appLogger + makeLogger = readTVarIO . snd . appLogger +siteLayout :: Maybe Html -- ^ Optionally override `pageHeading` + -> Widget -> Handler Html +siteLayout headingOverride widget = do + master <- getYesod + let AppSettings { appUserDefaults = UserDefaultConf{..}, .. } = appSettings master + + isModal <- hasCustomHeader HeaderIsModal + + mcurrentRoute <- getCurrentRoute + + -- Get the breadcrumbs, as defined in the YesodBreadcrumbs instance. + (title, parents) <- breadcrumbs + + -- let isParent :: Route UniWorX -> Bool + -- isParent r = r == (fst parents) + + defaultLinks' <- defaultLinks + let menu :: [MenuItem] + menu = defaultLinks' ++ maybe [] pageActions mcurrentRoute + + menuTypes <- mapM (\x -> (,,) <$> pure x <*> newIdent <*> toTextUrl x) =<< filterM menuItemAccessCallback menu + + isAuth <- isJust <$> maybeAuthId + + -- Lookup Favourites & Theme if possible -- TODO: cache this info in a cookie?! + (favourites', currentTheme) <- do + muid <- maybeAuthPair + case muid of + Nothing -> return ([],userDefaultTheme) + (Just (uid,user)) -> do + favs <- runDB $ E.select . E.from $ \(course `E.InnerJoin` courseFavourite) -> do + E.on (course E.^. CourseId E.==. courseFavourite E.^. CourseFavouriteCourse) + E.where_ (courseFavourite E.^. CourseFavouriteUser E.==. E.val uid) + E.orderBy [ E.asc $ course E.^. CourseShorthand ] + return course + return (favs, userTheme user) + favourites <- forM favourites' $ \(Entity _ c@Course{..}) + -> let courseRoute = CourseR courseTerm courseSchool courseShorthand CShowR + in do + items <- filterM menuItemAccessCallback (pageActions courseRoute) + items' <- forM items $ \i -> (i, ) <$> toTextUrl i + return (c, courseRoute, items') + + mmsgs <- if + | isModal -> getMessages + | otherwise -> do + applySystemMessages + authTagPivots <- fromMaybe Set.empty <$> getSessionJson SessionInactiveAuthTags + forM_ authTagPivots $ + \authTag -> addMessageWidget Info $ modal [whamlet|_{MsgUnauthorizedDisabledTag authTag}|] (Left $ SomeRoute (AuthPredsR, catMaybes [(toPathPiece GetReferer, ) . toPathPiece <$> mcurrentRoute])) + getMessages + + let highlight :: Route UniWorX -> Bool -- highlight last route in breadcrumbs, favorites taking priority + highlight = let crumbs = mcons mcurrentRoute $ fst <$> reverse parents + navItems = map snd3 favourites ++ map (urlRoute . menuItemRoute . view _1) menuTypes + highR = find (`elem` navItems) . uncurry (++) $ partition (`elem` map snd3 favourites) crumbs + in \r -> Just r == highR + favouriteTerms :: [TermIdentifier] + favouriteTerms = Set.toDescList $ foldMap (\(Course{..}, _, _) -> Set.singleton $ unTermKey courseTerm) favourites + favouriteTerm :: TermIdentifier -> [(Course, Route UniWorX, [(MenuItem, Text)])] + favouriteTerm tid = filter (\(Course{..}, _, _) -> unTermKey courseTerm == tid) favourites + + -- We break up the default layout into two components: + -- default-layout is the contents of the body tag, and + -- default-layout-wrapper is the entire page. Since the final + -- value passed to hamletToRepHtml cannot be a widget, this allows + -- you to use normal widget features in default-layout. + + navbar :: Widget + navbar = $(widgetFile "widgets/navbar") + asidenav :: Widget + asidenav = $(widgetFile "widgets/asidenav") + footer :: Widget + footer = $(widgetFile "widgets/footer") + contentHeadline :: Maybe Widget + contentHeadline = (toWidget <$> headingOverride) <|> (pageHeading =<< mcurrentRoute) + breadcrumbsWgt :: Widget + breadcrumbsWgt = $(widgetFile "widgets/breadcrumbs") + pageaction :: Widget + pageaction = $(widgetFile "widgets/pageaction") + -- functions to determine if there are page-actions (primary or secondary) + hasPageActions, hasSecondaryPageActions, hasPrimaryPageActions :: Bool + hasPageActions = hasPrimaryPageActions || hasSecondaryPageActions + hasSecondaryPageActions = any (is _PageActionSecondary) $ toListOf (traverse . _1 . _menuItemType) menuTypes + hasPrimaryPageActions = any (is _PageActionPrime) $ toListOf (traverse . _1 . _menuItemType) menuTypes + + pc <- widgetToPageContent $ do + addScript $ StaticR js_zepto_js + addScript $ StaticR js_fetchPolyfill_js + addScript $ StaticR js_urlPolyfill_js + addScript $ StaticR js_featureChecker_js + addScript $ StaticR js_flatpickr_js + addScript $ StaticR js_tabber_js + addStylesheet $ StaticR css_flatpickr_css + addStylesheet $ StaticR css_tabber_css + addStylesheet $ StaticR css_fonts_css + addStylesheet $ StaticR css_fontawesome_css + $(widgetFile "default-layout") + $(widgetFile "standalone/modal") + $(widgetFile "standalone/showHide") + $(widgetFile "standalone/inputs") + $(widgetFile "standalone/tooltip") + $(widgetFile "standalone/tabber") + $(widgetFile "standalone/alerts") + $(widgetFile "standalone/datepicker") + withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet") + + +applySystemMessages :: (MonadHandler m, HandlerSite m ~ UniWorX) => m () +applySystemMessages = liftHandlerT . runDB . runConduit $ selectSource [] [] .| C.mapM_ applyMessage + where + applyMessage (Entity smId SystemMessage{..}) = maybeT_ $ do + cID <- encrypt smId + void . assertM (== Authorized) . lift $ evalAccessDB (MessageR cID) False + + let sessionKey = "sm-" <> tshow (ciphertext cID) + _ <- assertM isNothing $ lookupSessionJson sessionKey :: MaybeT (YesodDB UniWorX) (Maybe ()) + setSessionJson sessionKey () + + (_, smTrans) <- MaybeT $ getSystemMessage appLanguages smId + let + (summary, content) = case smTrans of + Nothing -> (systemMessageSummary, systemMessageContent) + Just SystemMessageTranslation{..} -> (systemMessageTranslationSummary, systemMessageTranslationContent) + case summary of + Just s -> do + html <- withUrlRenderer [hamlet| + + #{s} + |] + addMessage systemMessageSeverity html + Nothing -> addMessage systemMessageSeverity content + -- Define breadcrumbs. instance YesodBreadcrumbs UniWorX where breadcrumb (AuthR _) = return ("Login" , Just HomeR) @@ -713,6 +1064,12 @@ instance YesodBreadcrumbs UniWorX where -- Others breadcrumb (CorrectionsR) = return ("Korrekturen", Just HomeR) breadcrumb (CorrectionsUploadR) = return ("Hochladen", Just CorrectionsR) + breadcrumb (MessageR _) = do + mayList <- (== Authorized) <$> evalAccess MessageListR False + return $ if + | mayList -> ("Statusmeldung", Just MessageListR) + | otherwise -> ("Statusmeldung", Just HomeR) + breadcrumb (MessageListR) = return ("Statusmeldungen", Just HomeR) breadcrumb _ = return ("Uni2work", Nothing) -- Default is no breadcrumb at all submissionList :: TermId -> CourseShorthand -> SheetName -> UserId -> DB [E.Value SubmissionId] @@ -730,66 +1087,95 @@ submissionList tid csh shn uid = E.select . E.from $ \(course `E.InnerJoin` shee -defaultLinks :: [MenuTypes] -defaultLinks = -- Define the menu items of the header. - [ NavbarAside $ MenuItem - { menuItemLabel = "Home" +defaultLinks :: (MonadHandler m, HandlerSite m ~ UniWorX) => m [MenuItem] +defaultLinks = fmap catMaybes . mapM runMaybeT $ -- Define the menu items of the header. + [ return MenuItem + { menuItemType = NavbarAside + , menuItemLabel = MsgMenuHome , menuItemIcon = Just "home" - , menuItemRoute = HomeR + , menuItemRoute = SomeRoute HomeR + , menuItemModal = False , menuItemAccessCallback' = return True } - , NavbarRight $ MenuItem - { menuItemLabel = "Impressum" + , return MenuItem + { menuItemType = Footer + , menuItemLabel = MsgMenuVersion , menuItemIcon = Just "book" - , menuItemRoute = VersionR + , menuItemRoute = SomeRoute VersionR + , menuItemModal = False , menuItemAccessCallback' = return True } - , NavbarRight $ MenuItem - { menuItemLabel = "Profil" + , do + mCurrentRoute <- getCurrentRoute + + return MenuItem + { menuItemType = NavbarRight + , menuItemLabel = MsgMenuHelp + , menuItemIcon = Just "question" + , menuItemRoute = SomeRoute (HelpR, catMaybes [(toPathPiece GetReferer, ) . toPathPiece <$> mCurrentRoute]) + , menuItemModal = True + , menuItemAccessCallback' = return True + } + , return MenuItem + { menuItemType = NavbarRight + , menuItemLabel = MsgMenuProfile , menuItemIcon = Just "cogs" - , menuItemRoute = ProfileR + , menuItemRoute = SomeRoute ProfileR + , menuItemModal = False , menuItemAccessCallback' = isJust <$> maybeAuthPair } - , NavbarSecondary $ MenuItem - { menuItemLabel = "Login" + , return MenuItem + { menuItemType = NavbarSecondary + , menuItemLabel = MsgMenuLogin , menuItemIcon = Just "sign-in-alt" - , menuItemRoute = AuthR LoginR + , menuItemRoute = SomeRoute $ AuthR LoginR + , menuItemModal = True , menuItemAccessCallback' = isNothing <$> maybeAuthPair } - , NavbarSecondary $ MenuItem - { menuItemLabel = "Logout" + , return MenuItem + { menuItemType = NavbarSecondary + , menuItemLabel = MsgMenuLogout , menuItemIcon = Just "sign-out-alt" - , menuItemRoute = AuthR LogoutR + , menuItemRoute = SomeRoute $ AuthR LogoutR + , menuItemModal = False , menuItemAccessCallback' = isJust <$> maybeAuthPair } - , NavbarAside $ MenuItem - { menuItemLabel = "Kurse" + , return MenuItem + { menuItemType = NavbarAside + , menuItemLabel = MsgMenuCourseList , menuItemIcon = Just "calendar-alt" - , menuItemRoute = CourseListR + , menuItemRoute = SomeRoute CourseListR + , menuItemModal = False , menuItemAccessCallback' = return True } - , NavbarAside $ MenuItem - { menuItemLabel = "Semester" + , return MenuItem + { menuItemType = NavbarAside + , menuItemLabel = MsgMenuTermShow , menuItemIcon = Just "graduation-cap" - , menuItemRoute = TermShowR + , menuItemRoute = SomeRoute TermShowR + , menuItemModal = False , menuItemAccessCallback' = return True } - , NavbarAside $ MenuItem - { menuItemLabel = "Korrekturen" + , return MenuItem + { menuItemType = NavbarAside + , menuItemLabel = MsgMenuCorrections , menuItemIcon = Just "check" - , menuItemRoute = CorrectionsR + , menuItemRoute = SomeRoute CorrectionsR + , menuItemModal = False , menuItemAccessCallback' = return True } - , NavbarAside $ MenuItem - { menuItemLabel = "Benutzer" + , return MenuItem + { menuItemType = NavbarAside + , menuItemLabel = MsgMenuUsers , menuItemIcon = Just "users" - , menuItemRoute = UsersR + , menuItemRoute = SomeRoute UsersR + , menuItemModal = False , menuItemAccessCallback' = return True -- Creates a LOOP: (Authorized ==) <$> isAuthorized UsersR False } ] -pageActions :: Route UniWorX -> [MenuTypes] +pageActions :: Route UniWorX -> [MenuItem] {- Icons: https://fontawesome.com/icons?d=gallery Guideline: use icons without boxes/frames, only non-pro @@ -805,58 +1191,96 @@ pageActions (HomeR) = -- , menuItemAccessCallback' = return True -- } -- , - NavbarAside $ MenuItem - { menuItemLabel = "AdminDemo" + MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuAdminTest , menuItemIcon = Just "screwdriver" - , menuItemRoute = AdminTestR + , menuItemRoute = SomeRoute AdminTestR + , menuItemModal = False + , menuItemAccessCallback' = return True + } + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuMessageList + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute MessageListR + , menuItemModal = False + , menuItemAccessCallback' = return True + } + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuAdminErrMsg + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute AdminErrMsgR + , menuItemModal = False , menuItemAccessCallback' = return True } ] pageActions (ProfileR) = - [ PageActionPrime $ MenuItem - { menuItemLabel = "Gespeicherte Daten anzeigen" + [ MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuProfileData , menuItemIcon = Just "book" - , menuItemRoute = ProfileDataR + , menuItemRoute = SomeRoute ProfileDataR + , menuItemModal = False , menuItemAccessCallback' = return True } + -- , MenuItem + -- { menuItemType = PageActionSecondary + -- , menuItemLabel = MsgMenuAuthPreds + -- , menuItemIcon = Nothing + -- , menuItemRoute = SomeRoute AuthPredsR + -- , menuItemModal = True + -- , menuItemAccessCallback' = return True + -- } ] pageActions TermShowR = - [ PageActionPrime $ MenuItem - { menuItemLabel = "Neues Semester anlegen" + [ MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuTermCreate , menuItemIcon = Nothing - , menuItemRoute = TermEditR + , menuItemRoute = SomeRoute TermEditR + , menuItemModal = False , menuItemAccessCallback' = return True } ] pageActions (TermCourseListR tid) = - [ PageActionPrime $ MenuItem - { menuItemLabel = "Neuen Kurs anlegen" + [ MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuCourseNew , menuItemIcon = Just "book" - , menuItemRoute = CourseNewR + , menuItemRoute = SomeRoute CourseNewR + , menuItemModal = False , menuItemAccessCallback' = return True } - , PageActionPrime $ MenuItem - { menuItemLabel = "Semster editieren" + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuTermEdit , menuItemIcon = Nothing - , menuItemRoute = TermEditExistR tid + , menuItemRoute = SomeRoute $ TermEditExistR tid + , menuItemModal = False , menuItemAccessCallback' = return True } ] pageActions (CourseListR) = - [ PageActionPrime $ MenuItem - { menuItemLabel = "Neuen Kurs anlegen" + [ MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuCourseNew , menuItemIcon = Just "book" - , menuItemRoute = CourseNewR + , menuItemRoute = SomeRoute CourseNewR + , menuItemModal = False , menuItemAccessCallback' = return True } ] pageActions (CourseR tid ssh csh CShowR) = - [ PageActionPrime $ MenuItem - { menuItemLabel = "Übungsblätter" + [ MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuSheetList , menuItemIcon = Nothing - , menuItemRoute = CourseR tid ssh csh SheetListR + , menuItemRoute = SomeRoute $ CourseR tid ssh csh SheetListR + , menuItemModal = False , menuItemAccessCallback' = do --TODO always show for lecturer - let sheetRouteAccess shn = (== Authorized) <$> (isAuthorized (CSheetR tid ssh csh shn SShowR) False) + let sheetRouteAccess shn = (== Authorized) <$> evalAccess (CSheetR tid ssh csh shn SShowR) False muid <- maybeAuthId (sheets,lecturer) <- runDB $ do cid <- getKeyBy404 $ TermSchoolCourseShort tid ssh csh @@ -867,116 +1291,306 @@ pageActions (CourseR tid ssh csh CShowR) = return (sheets,lecturer) or2M (return lecturer) $ anyM sheets sheetRouteAccess } - , PageActionPrime $ MenuItem - { menuItemLabel = "Abgaben" - , menuItemIcon = Nothing - , menuItemRoute = CourseR tid ssh csh CCorrectionsR + ] ++ pageActions (CourseR tid ssh csh SheetListR) ++ + [ MenuItem + { menuItemType = PageActionSecondary + , menuItemLabel = MsgMenuCourseEdit + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute $ CourseR tid ssh csh CEditR + , menuItemModal = False , menuItemAccessCallback' = return True } - , PageActionPrime $ MenuItem - { menuItemLabel = "Neues Übungsblatt anlegen" - , menuItemIcon = Nothing - , menuItemRoute = CourseR tid ssh csh SheetNewR + , MenuItem + { menuItemType = PageActionSecondary + , menuItemLabel = MsgMenuCourseClone + , menuItemIcon = Just "copy" + , menuItemRoute = SomeRoute (CourseNewR, [("tid", toPathPiece tid), ("ssh", toPathPiece ssh), ("csh", toPathPiece csh)]) + , menuItemModal = False , menuItemAccessCallback' = return True } - , PageActionSecondary $ MenuItem - { menuItemLabel = "Kurs editieren" - , menuItemIcon = Nothing - , menuItemRoute = CourseR tid ssh csh CEditR - , menuItemAccessCallback' = return True - } - , PageActionSecondary $ MenuItem - { menuItemLabel = "Neuen Kurs klonen" - , menuItemIcon = Nothing - , menuItemRoute = CourseNewTemplateR (Just tid) (Just ssh) (Just csh) + , MenuItem + { menuItemType = PageActionSecondary + , menuItemLabel = MsgMenuCourseDelete + , menuItemIcon = Just "trash" + , menuItemRoute = SomeRoute $ CourseR tid ssh csh CDeleteR + , menuItemModal = False , menuItemAccessCallback' = return True } ] pageActions (CourseR tid ssh csh SheetListR) = - [ PageActionPrime $ MenuItem - { menuItemLabel = "Neues Übungsblatt anlegen" + [ MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuSheetCurrent , menuItemIcon = Nothing - , menuItemRoute = CourseR tid ssh csh SheetNewR + , menuItemRoute = SomeRoute $ CourseR tid ssh csh SheetCurrentR + , menuItemModal = False + , menuItemAccessCallback' = runDB . maybeT (return False) $ do + void . MaybeT $ sheetCurrent tid ssh csh + return True + } + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuSheetOldUnassigned + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute $ CourseR tid ssh csh SheetOldUnassigned + , menuItemModal = False + , menuItemAccessCallback' = runDB . maybeT (return False) $ do + void . MaybeT $ sheetOldUnassigned tid ssh csh + return True + } + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuSubmissions + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute $ CourseR tid ssh csh CCorrectionsR + , menuItemModal = False + , menuItemAccessCallback' = return True + } + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuCorrectionsOwn + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute (CorrectionsR, [ ("corrections-term" , termToText $ unTermKey tid) + , ("corrections-school", CI.original $ unSchoolKey ssh) + , ("corrections-course", CI.original csh) + ]) + , menuItemModal = False + , menuItemAccessCallback' = do + muid <- maybeAuthId + case muid of + Nothing -> return False + (Just uid) -> do + [E.Value ok] <- runDB . E.select . return . E.exists . E.from $ \(course `E.InnerJoin` sheet `E.InnerJoin` submission) -> do + E.on $ submission E.^. SubmissionSheet E.==. sheet E.^. SheetId + E.on $ sheet E.^. SheetCourse E.==. course E.^. CourseId + E.where_ $ submission E.^. SubmissionRatingBy E.==. E.just (E.val uid) + E.&&. course E.^. CourseTerm E.==. E.val tid + E.&&. course E.^. CourseSchool E.==. E.val ssh + E.&&. course E.^. CourseShorthand E.==. E.val csh + return ok + } + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuSheetNew + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute $ CourseR tid ssh csh SheetNewR + , menuItemModal = False , menuItemAccessCallback' = return True } ] pageActions (CSheetR tid ssh csh shn SShowR) = - [ PageActionPrime $ MenuItem - { menuItemLabel = "Abgabe anlegen" + [ MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuSubmissionNew , menuItemIcon = Nothing - , menuItemRoute = CSheetR tid ssh csh shn SubmissionNewR + , menuItemRoute = SomeRoute $ CSheetR tid ssh csh shn SubmissionNewR + , menuItemModal = True , menuItemAccessCallback' = runDB . maybeT (return False) $ do uid <- MaybeT $ liftHandlerT maybeAuthId submissions <- lift $ submissionList tid csh shn uid guard $ null submissions return True } - , PageActionPrime $ MenuItem - { menuItemLabel = "Abgabe ansehen" + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuSubmissionOwn , menuItemIcon = Nothing - , menuItemRoute = CSheetR tid ssh csh shn SubmissionOwnR + , menuItemRoute = SomeRoute $ CSheetR tid ssh csh shn SubmissionOwnR + , menuItemModal = False , menuItemAccessCallback' = runDB . maybeT (return False) $ do uid <- MaybeT $ liftHandlerT maybeAuthId submissions <- lift $ submissionList tid csh shn uid guard . not $ null submissions return True } - , PageActionPrime $ MenuItem - { menuItemLabel = "Korrektoren" + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuCorrectionsOwn , menuItemIcon = Nothing - , menuItemRoute = CSheetR tid ssh csh shn SCorrR + , menuItemRoute = SomeRoute (CorrectionsR, [ ("corrections-term" , termToText $ unTermKey tid) + , ("corrections-school", CI.original $ unSchoolKey ssh) + , ("corrections-course", CI.original csh) + , ("corrections-sheet" , CI.original shn) + ]) + , menuItemModal = False + , menuItemAccessCallback' = (== Authorized) <$> evalAccessCorrector tid ssh csh + } + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuCorrectors + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute $ CSheetR tid ssh csh shn SCorrR + , menuItemModal = False , menuItemAccessCallback' = return True } - , PageActionPrime $ MenuItem - { menuItemLabel = "Abgaben" + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuSubmissions , menuItemIcon = Nothing - , menuItemRoute = CSheetR tid ssh csh shn SSubsR + , menuItemRoute = SomeRoute $ CSheetR tid ssh csh shn SSubsR + , menuItemModal = False , menuItemAccessCallback' = return True } - , PageActionPrime $ MenuItem - { menuItemLabel = "Blatt Editieren" + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuSheetEdit , menuItemIcon = Nothing - , menuItemRoute = CSheetR tid ssh csh shn SEditR + , menuItemRoute = SomeRoute $ CSheetR tid ssh csh shn SEditR + , menuItemModal = False + , menuItemAccessCallback' = return True + } + , MenuItem + { menuItemType = PageActionSecondary + , menuItemLabel = MsgMenuSheetClone + , menuItemIcon = Just "copy" + , menuItemRoute = SomeRoute (CourseR tid ssh csh SheetNewR, [("shn", toPathPiece shn)]) + , menuItemModal = False + , menuItemAccessCallback' = return True + } + , MenuItem + { menuItemType = PageActionSecondary + , menuItemLabel = MsgMenuSheetDelete + , menuItemIcon = Just "trash" + , menuItemRoute = SomeRoute $ CSheetR tid ssh csh shn SDelR + , menuItemModal = False , menuItemAccessCallback' = return True } ] pageActions (CSheetR tid ssh csh shn SSubsR) = - [ PageActionPrime $ MenuItem - { menuItemLabel = "Korrektoren" + [ MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuCorrectors , menuItemIcon = Nothing - , menuItemRoute = CSheetR tid ssh csh shn SCorrR + , menuItemRoute = SomeRoute $ CSheetR tid ssh csh shn SCorrR + , menuItemModal = False , menuItemAccessCallback' = return True } ] pageActions (CSubmissionR tid ssh csh shn cid SubShowR) = - [ PageActionPrime $ MenuItem - { menuItemLabel = "Korrektur" + [ MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuCorrection , menuItemIcon = Nothing - , menuItemRoute = CSubmissionR tid ssh csh shn cid CorrectionR + , menuItemRoute = SomeRoute $ CSubmissionR tid ssh csh shn cid CorrectionR + , menuItemModal = False + , menuItemAccessCallback' = return True + } + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgCorrectorAssignTitle + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute $ CSubmissionR tid ssh csh shn cid SAssignR + , menuItemModal = True + , menuItemAccessCallback' = return True + } + , MenuItem + { menuItemType = PageActionSecondary + , menuItemLabel = MsgMenuSubmissionDelete + , menuItemIcon = Just "trash" + , menuItemRoute = SomeRoute $ CSubmissionR tid ssh csh shn cid SubDelR + , menuItemModal = False + , menuItemAccessCallback' = return True + } + ] +pageActions (CSubmissionR tid ssh csh shn cid CorrectionR) = + [ MenuItem + { menuItemType = PageActionSecondary + , menuItemLabel = MsgMenuSubmissionDelete + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute $ CSubmissionR tid ssh csh shn cid SubDelR + , menuItemModal = False , menuItemAccessCallback' = return True } ] pageActions (CSheetR tid ssh csh shn SCorrR) = - [ PageActionPrime $ MenuItem - { menuItemLabel = "Abgaben" + [ MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuSubmissions , menuItemIcon = Nothing - , menuItemRoute = CSheetR tid ssh csh shn SSubsR + , menuItemRoute = SomeRoute $ CSheetR tid ssh csh shn SSubsR + , menuItemModal = False , menuItemAccessCallback' = return True } - , PageActionSecondary $ MenuItem - { menuItemLabel = "Edit " <> (CI.original shn) + , MenuItem + { menuItemType = PageActionSecondary + , menuItemLabel = MsgMenuSheetEdit , menuItemIcon = Nothing - , menuItemRoute = CSheetR tid ssh csh shn SEditR + , menuItemRoute = SomeRoute $ CSheetR tid ssh csh shn SEditR + , menuItemModal = False , menuItemAccessCallback' = return True } ] pageActions (CorrectionsR) = - [ PageActionPrime $ MenuItem - { menuItemLabel = "Korrekturen hochladen" + [ MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuCorrectionsUpload , menuItemIcon = Nothing - , menuItemRoute = CorrectionsUploadR + , menuItemRoute = SomeRoute CorrectionsUploadR + , menuItemModal = True , menuItemAccessCallback' = return True } + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuCorrectionsCreate + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute CorrectionsCreateR + , menuItemModal = False + , menuItemAccessCallback' = runDB . maybeT (return False) $ do + uid <- MaybeT $ liftHandlerT maybeAuthId + [E.Value sheetCount] <- lift . E.select . E.from $ \(course `E.InnerJoin` sheet) -> do + E.on $ course E.^. CourseId E.==. sheet E.^. SheetCourse + let + isCorrector' = E.exists . E.from $ \sheetCorrector -> E.where_ + $ sheetCorrector E.^. SheetCorrectorUser E.==. E.val uid + E.&&. sheetCorrector E.^. SheetCorrectorSheet E.==. sheet E.^. SheetId + isLecturer = E.exists . E.from $ \lecturer -> E.where_ + $ lecturer E.^. LecturerUser E.==. E.val uid + E.&&. lecturer E.^. LecturerCourse E.==. course E.^. CourseId + E.where_ $ sheet E.^. SheetSubmissionMode E.==. E.val CorrectorSubmissions + E.&&. ( isCorrector' E.||. isLecturer ) + return E.countRows + return $ (sheetCount :: Int) /= 0 + } + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuCorrectionsGrade + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute CorrectionsGradeR + , menuItemModal = False + , menuItemAccessCallback' = return True + } + ] +pageActions (CorrectionsGradeR) = + [ MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuCorrectionsUpload + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute CorrectionsUploadR + , menuItemModal = True + , menuItemAccessCallback' = return True + } + , MenuItem + { menuItemType = PageActionPrime + , menuItemLabel = MsgMenuCorrectionsCreate + , menuItemIcon = Nothing + , menuItemRoute = SomeRoute CorrectionsCreateR + , menuItemModal = False + , menuItemAccessCallback' = runDB . maybeT (return False) $ do + uid <- MaybeT $ liftHandlerT maybeAuthId + [E.Value sheetCount] <- lift . E.select . E.from $ \(course `E.InnerJoin` sheet) -> do + E.on $ course E.^. CourseId E.==. sheet E.^. SheetCourse + let + isCorrector' = E.exists . E.from $ \sheetCorrector -> E.where_ + $ sheetCorrector E.^. SheetCorrectorUser E.==. E.val uid + E.&&. sheetCorrector E.^. SheetCorrectorSheet E.==. sheet E.^. SheetId + isLecturer = E.exists . E.from $ \lecturer -> E.where_ + $ lecturer E.^. LecturerUser E.==. E.val uid + E.&&. lecturer E.^. LecturerCourse E.==. course E.^. CourseId + E.where_ $ sheet E.^. SheetSubmissionMode E.==. E.val CorrectorSubmissions + E.&&. ( isCorrector' E.||. isLecturer ) + return E.countRows + return $ (sheetCount :: Int) /= 0 + } ] pageActions _ = [] @@ -995,8 +1609,12 @@ pageHeading (AdminTestR) = Just $ [whamlet|Internal Code Demonstration Page|] pageHeading (AdminUserR _) = Just $ [whamlet|User Display for Admin|] +pageHeading (AdminErrMsgR) + = Just $ i18nHeading MsgErrMsgHeading pageHeading (VersionR) = Just $ i18nHeading MsgImpressumHeading +pageHeading (HelpR) + = Just $ i18nHeading MsgHelpRequest pageHeading ProfileR = Just $ i18nHeading MsgProfileHeading @@ -1048,7 +1666,7 @@ pageHeading (CSheetR tid ssh csh shn SEditR) = Just $ i18nHeading $ MsgSheetEditHead tid ssh csh shn pageHeading (CSheetR tid ssh csh shn SDelR) = Just $ i18nHeading $ MsgSheetDelHead tid ssh csh shn -pageHeading (CSheetR tid ssh csh shn SSubsR) +pageHeading (CSheetR _tid _ssh _csh shn SSubsR) = Just $ i18nHeading $ MsgSubmissionsSheet shn pageHeading (CSheetR tid ssh csh shn SubmissionNewR) = Just $ i18nHeading $ MsgSubmissionEditHead tid ssh csh shn @@ -1060,7 +1678,7 @@ pageHeading (CSubmissionR tid ssh csh shn _ SubShowR) -- TODO: Rethink this one! pageHeading (CSubmissionR tid ssh csh shn cid CorrectionR) = Just $ i18nHeading $ MsgCorrectionHead tid ssh csh shn cid -- (CSubmissionR tid csh shn cid SubDownloadR) -- just a download -pageHeading (CSheetR tid ssh csh shn SCorrR) +pageHeading (CSheetR _tid _ssh _csh shn SCorrR) = Just $ i18nHeading $ MsgCorrectorsHead shn -- (CSheetR tid ssh csh shn SFileR) -- just for Downloads @@ -1068,6 +1686,14 @@ pageHeading CorrectionsR = Just $ i18nHeading MsgCorrectionsTitle pageHeading CorrectionsUploadR = Just $ i18nHeading MsgCorrUpload +pageHeading CorrectionsCreateR + = Just $ i18nHeading MsgCorrCreate +pageHeading CorrectionsGradeR + = Just $ i18nHeading MsgCorrGrade +pageHeading (MessageR _) + = Just $ i18nHeading MsgSystemMessageHeading +pageHeading MessageListR + = Just $ i18nHeading MsgSystemMessageListHeading -- TODO: add headings for more single course- and single term-pages pageHeading _ @@ -1080,6 +1706,7 @@ routeNormalizers = , ncSchool , ncCourse , ncSheet + , verifySubmission ] where normalizeRender route = route <$ do @@ -1117,8 +1744,17 @@ routeNormalizers = Entity _ Sheet{..} <- MaybeT . lift . getBy $ CourseSheet cid shn hasChanged shn sheetName return $ CSheetR tid ssh csh sheetName subRoute + verifySubmission = maybeOrig $ \route -> do + CSubmissionR _tid _ssh _csh _shn cID sr <- return route + sId <- decrypt cID + Submission{submissionSheet} <- lift . lift $ get404 sId + Sheet{sheetCourse, sheetName} <- lift . lift $ get404 submissionSheet + Course{courseTerm, courseSchool, courseShorthand} <- lift . lift $ get404 sheetCourse + let newRoute = CSubmissionR courseTerm courseSchool courseShorthand sheetName cID sr + tell . Any $ route /= newRoute + return newRoute + - -- How to run database actions. instance YesodPersist UniWorX where type YesodPersistBackend UniWorX = SqlBackend @@ -1176,11 +1812,11 @@ instance YesodAuth UniWorX where acceptExisting = maybe (UserError $ IdentifierNotFound credsIdent) (Authenticated . entityKey) <$> getBy uAuth $logDebugS "auth" $ tshow Creds{..} - AppSettings{ appUserDefaults = UserDefaultConf{..}, ..} <- getsYesod appSettings + UniWorX{ appSettings = AppSettings{ appUserDefaults = UserDefaultConf{..}, ..}, .. } <- getYesod - flip catches excHandlers $ case appLdapConf of - Just ldapConf -> fmap (either id id) . runExceptT $ do - ldapData <- campusUser ldapConf $ Creds credsPlugin (CI.original userIdent) credsExtra + flip catches excHandlers $ case (,) <$> appLdapConf <*> appLdapPool of + Just (ldapConf, ldapPool) -> fmap (either id id) . runExceptT $ do + ldapData <- campusUser ldapConf ldapPool $ Creds credsPlugin (CI.original userIdent) credsExtra $logDebugS "LDAP" $ "Successful LDAP lookup: " <> tshow ldapData let @@ -1188,7 +1824,7 @@ instance YesodAuth UniWorX where userEmail' = lookup (Attr "mail") ldapData userDisplayName' = lookup (Attr "displayName") ldapData userSurname' = lookup (Attr "sn") ldapData - + userAuthentication | isPWHash = error "PWHash should only work for users that are already known" | otherwise = AuthLDAP @@ -1222,12 +1858,14 @@ instance YesodAuth UniWorX where let newUser = User - { userMaxFavourites = userDefaultMaxFavourites - , userTheme = userDefaultTheme - , userDateTimeFormat = userDefaultDateTimeFormat - , userDateFormat = userDefaultDateFormat - , userTimeFormat = userDefaultTimeFormat - , userDownloadFiles = userDefaultDownloadFiles + { userMaxFavourites = userDefaultMaxFavourites + , userTheme = userDefaultTheme + , userDateTimeFormat = userDefaultDateTimeFormat + , userDateFormat = userDefaultDateFormat + , userTimeFormat = userDefaultTimeFormat + , userDownloadFiles = userDefaultDownloadFiles + , userNotificationSettings = def + , userMailLanguages = def , .. } userUpdate = [ UserMatrikelnummer =. userMatrikelnummer @@ -1262,14 +1900,16 @@ instance YesodAuth UniWorX where where insertMaybe key val = get key >>= maybe (insert_ val) (\_ -> return ()) - authPlugins (appSettings -> AppSettings{..}) = catMaybes - [ campusLogin <$> appLdapConf + authPlugins (UniWorX{ appSettings = AppSettings{..}, appLdapPool }) = catMaybes + [ campusLogin <$> appLdapConf <*> appLdapPool , Just . hashLogin $ pwHashAlgorithm appAuthPWHash , dummyLogin <$ guard appAuthDummyLogin ] authHttpManager = getHttpManager + renderAuthMessage _ _ = Auth.germanMessage -- TODO + instance YesodAuthPersist UniWorX -- Useful when writing code that is re-usable outside of the Handler context. @@ -1279,13 +1919,37 @@ instance HasHttpManager UniWorX where getHttpManager = appHttpManager unsafeHandler :: UniWorX -> Handler a -> IO a -unsafeHandler = Unsafe.fakeHandlerGetLogger appLogger +unsafeHandler f h = do + logger <- makeLogger f + Unsafe.fakeHandlerGetLogger (const logger) f h + + +instance YesodMail UniWorX where + defaultFromAddress = getsYesod $ appMailFrom . appSettings + mailObjectIdDomain = getsYesod $ appMailObjectDomain . appSettings + mailVerp = getsYesod $ appMailVerp . appSettings + mailDateTZ = return appTZ + mailSmtp act = do + pool <- maybe (throwM MailNotAvailable) return =<< getsYesod appSmtpPool + withResource pool act + mailT ctx mail = defMailT ctx $ do + void setMailObjectId + setDateCurrent + replaceMailHeader "Auto-Submitted" $ Just "auto-generated" + + ret <- mail + + setMailSmtpData + return ret instance (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX) => MonadCrypto m where type MonadCryptoKey m = CryptoIDKey cryptoIDKey f = getsYesod appCryptoIDKey >>= f +instance {-# OVERLAPPING #-} (Monad m, MonadHandler m, HandlerSite m ~ UniWorX) => MonadSecretBox m where + secretBoxKey = getsYesod appSecretBoxKey + -- Note: Some functionality previously present in the scaffolding has been -- moved to documentation in the Wiki. Following are some hopefully helpful -- links: diff --git a/src/Handler/Admin.hs b/src/Handler/Admin.hs index 156961629..17bc943b9 100644 --- a/src/Handler/Admin.hs +++ b/src/Handler/Admin.hs @@ -1,24 +1,19 @@ -{-# LANGUAGE RecordWildCards #-} -{-# LANGUAGE NoImplicitPrelude #-} -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE ViewPatterns #-} -{-# LANGUAGE TemplateHaskell #-} -{-# LANGUAGE QuasiQuotes #-} -{-# LANGUAGE MultiParamTypeClasses #-} -{-# LANGUAGE TypeFamilies #-} -{-# LANGUAGE FlexibleContexts #-} - module Handler.Admin where import Import import Handler.Utils +import Jobs + +import Data.Aeson.Encode.Pretty (encodePrettyToTextBuilder) + +import Control.Monad.Trans.Except -- import Data.Time -- import qualified Data.Text as T -- import Data.Function ((&)) -- import Yesod.Form.Bootstrap3 -import Web.PathPieces (showToPathPiece, readFromPathPiece) +import Database.Persist.Sql (fromSqlKey) -- import Colonnade hiding (fromMaybe) -- import Yesod.Colonnade @@ -26,46 +21,96 @@ import Web.PathPieces (showToPathPiece, readFromPathPiece) -- import qualified Data.UUID.Cryptographic as UUID -- BEGIN - Buttons needed only here -data CreateButton = CreateMath | CreateInf -- Dummy for Example - deriving (Enum, Eq, Ord, Bounded, Read, Show) +data ButtonCreate = CreateMath | CreateInf -- Dummy for Example + deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable) +instance Universe ButtonCreate +instance Finite ButtonCreate -instance PathPiece CreateButton where -- for displaying the button only, not really for paths - toPathPiece = showToPathPiece - fromPathPiece = readFromPathPiece +nullaryPathPiece ''ButtonCreate camelToPathPiece -instance Button UniWorX CreateButton where - label CreateMath = [whamlet|Mathematik|] - label CreateInf = "Informatik" +instance Button UniWorX ButtonCreate where + btnLabel CreateMath = [whamlet|Mathematik|] + btnLabel CreateInf = "Informatik" - cssClass CreateMath = BCInfo - cssClass CreateInf = BCPrimary + btnClasses CreateMath = [BCIsButton, BCInfo] + btnClasses CreateInf = [BCIsButton, BCPrimary] -- END Button needed here +emailTestForm :: AForm (HandlerT UniWorX IO) (Email, MailContext) +emailTestForm = (,) + <$> areq emailField (fslI MsgMailTestFormEmail) Nothing + <*> ( MailContext + <$> (MailLanguages <$> areq (reorderField appLanguagesOpts) (fslI MsgMailTestFormLanguages) Nothing) + <*> (toMailDateTimeFormat + <$> areq (selectField $ dateTimeFormatOptions SelFormatDateTime) (fslI MsgDateTimeFormat) Nothing + <*> areq (selectField $ dateTimeFormatOptions SelFormatDate) (fslI MsgDateFormat) Nothing + <*> areq (selectField $ dateTimeFormatOptions SelFormatTime) (fslI MsgTimeFormat) Nothing + ) + ) + <* submitButton + where + toMailDateTimeFormat dt d t = \case + SelFormatDateTime -> dt + SelFormatDate -> d + SelFormatTime -> t -getAdminTestR :: Handler Html -- Demo Page. Referenzimplementierungen sollte hier gezeigt werden! -getAdminTestR = do - (btnWdgt, btnEnctype) <- generateFormPost (buttonForm :: Form CreateButton) - defaultLayout $ do - -- setTitle "Uni2work Admin Testpage" - $(widgetFile "adminTest") -postAdminTestR :: Handler Html +getAdminTestR, postAdminTestR :: Handler Html -- Demo Page. Referenzimplementierungen sollte hier gezeigt werden! +getAdminTestR = postAdminTestR postAdminTestR = do - ((btnResult,_), _) <- runFormPost $ buttonForm + ((btnResult, btnWdgt), btnEnctype) <- runFormPost $ identifyForm "buttons" (buttonForm :: Form ButtonCreate) case btnResult of (FormSuccess CreateInf) -> addMessage Info "Informatik-Knopf gedrückt" (FormSuccess CreateMath) -> addMessage Warning "Knopf Mathematik erkannt" + FormMissing -> return () _other -> addMessage Warning "KEIN Knopf erkannt" - getAdminTestR + + ((emailResult, emailWidget), emailEnctype) <- runFormPost . identifyForm "email" $ renderAForm FormStandard emailTestForm + case emailResult of + (FormSuccess (email, ls)) -> do + jId <- runDB $ do + jId <- queueJob $ JobSendTestEmail email ls + addMessage Success [shamlet|Email-test gestartet (Job ##{tshow (fromSqlKey jId)})|] + return jId + writeJobCtl $ JobCtlPerform jId + FormMissing -> return () + (FormFailure errs) -> forM_ errs $ addMessage Error . toHtml + + let emailWidget' = [whamlet| +