Compare commits

..

68 commits

Author SHA1 Message Date
7b29a58a22 Update flake.nix 2025-09-22 12:04:49 +02:00
16bcee802d added some text capabilities 2025-07-14 22:07:57 +02:00
2ce5997c93 merge 2025-07-14 16:07:33 +02:00
c4b57d7a29 api ready 2025-07-14 16:06:17 +02:00
c9b7bdd84b Update README.md 2025-07-13 12:55:05 +02:00
67d88bd31b updated readmi 2025-07-03 09:44:55 +01:00
1e661031fb Created a route to fetch recent modifications as a csv to allow creating clients 2025-07-03 09:42:31 +01:00
de60936cd2 moved hamlet to widgetfiles 2025-07-02 17:37:55 +01:00
c63309b9e8 removed middleware 2025-07-02 17:13:50 +01:00
6b8781760c update readme 2025-07-02 16:57:53 +01:00
f0951506a0 Merge branch 'main' of git.stuce.ch:stuce/sTodo 2025-07-02 16:54:59 +01:00
2353d2fdc9 refactor to make it easier to navigate 2025-07-02 16:54:52 +01:00
3511f257e6 Update README.md 2025-06-27 13:43:56 +02:00
1b591f05d8 fixed permissions 2025-06-26 13:11:57 +02:00
1c395150e0 fixed permissions 2025-06-26 13:07:29 +02:00
2bd43b0df5 . 2025-06-26 13:00:28 +02:00
f058168db9 . 2025-06-26 12:50:33 +02:00
842b6c8936 . 2025-06-26 12:49:15 +02:00
ddda1e7566 . 2025-06-26 12:48:14 +02:00
4984a836b4 . 2025-06-26 12:42:50 +02:00
255103af13 . 2025-06-26 12:42:13 +02:00
3ed15bae87 . 2025-06-26 12:41:02 +02:00
8b618bfdbb . 2025-06-26 12:39:36 +02:00
62bf6f5933 update flake 2025-06-26 12:37:07 +02:00
90ef6829e9 fix small mistake 2025-06-26 12:31:28 +02:00
71d1e9bc34 fix small mistake 2025-06-26 12:30:19 +02:00
e51fac7b6a updated for r3 2025-06-26 12:23:00 +02:00
d31a3d6330 updated for r3 2025-06-26 12:22:33 +02:00
ee514454f7 added group sharing 2025-06-26 09:53:56 +01:00
20ce55f22a Merge branch 'main' of git.stuce.ch:stuce/sTodo 2025-06-25 17:03:08 +01:00
81ca02948b qol improvements 2025-06-25 17:03:00 +01:00
ee6d6de212 Update README.md 2025-06-25 17:05:46 +02:00
596c830feb Update README.md
tested, it works
2025-06-25 17:03:58 +02:00
e2c414f108 fixed sha 2025-06-25 16:26:04 +02:00
c259f6dc1a fixed link 2025-06-25 16:19:18 +02:00
c84fa67136 . 2025-06-25 15:47:12 +02:00
b601bdd796 updated tarball 2025-06-25 14:08:37 +02:00
ce2dd6c750 decided not to use auth in the end 2025-06-25 11:13:18 +01:00
ab707af870 implemented yesodauth, now need use it in handler 2025-06-23 11:25:55 +01:00
4b9d2e8733 user is no longer hardcoded 2025-06-13 15:12:47 +01:00
113990ac54 updated readme documentation 2025-06-13 13:36:50 +01:00
ad0d0c3a55 added users 2025-06-10 12:47:22 +02:00
b2db2dc9f9 added users 2025-06-10 12:46:32 +02:00
2866223518 added users 2025-06-10 12:44:28 +02:00
95ffbf6300 added some security options 2025-06-10 12:32:37 +02:00
85bae9d67d updated flake 2025-06-10 11:20:24 +02:00
a2bfb5eb9e flake updated 2025-06-10 11:12:55 +02:00
2f51d7a569 flake updated 2025-06-10 11:01:45 +02:00
fe57ada600 flake updated 2025-06-10 10:59:27 +02:00
f65eb94d18 flake updated 2025-06-10 10:57:11 +02:00
fe22b61a15 flake updated 2025-06-10 10:54:30 +02:00
39e68d7d82 flake updated 2025-06-10 10:44:39 +02:00
0aac40808e flake updated 2025-06-10 10:33:37 +02:00
b04ab998ce flake updated 2025-06-10 10:32:16 +02:00
6be9bd7802 flake updated 2025-06-10 10:30:40 +02:00
1bd3be3c0f updated flake 2025-06-10 10:24:58 +02:00
b8cebf3584 updated flake 2025-06-10 10:21:33 +02:00
ebb3d06881 updated flake 2025-06-10 09:42:30 +02:00
0997968937 updated flake 2025-06-10 09:38:28 +02:00
2a67ab5212 updated flake 2025-06-10 09:19:31 +02:00
054d8d6807 updated flake 2025-06-10 09:15:08 +02:00
993d1ee521 updated flake 2025-06-10 09:07:03 +02:00
3715367605 updated flake 2025-06-10 08:53:44 +02:00
05cfe09e16 updated flake 2025-06-10 08:51:23 +02:00
876da44f49 updated flake 2025-06-10 08:48:46 +02:00
0952b28e6b updated flake 2025-06-10 08:47:47 +02:00
57d862183d added etc 2025-06-10 08:44:51 +02:00
cb9ae44ec4 added nix support 2025-06-09 22:06:51 +02:00
29 changed files with 729 additions and 587 deletions

View file

@ -1,10 +1,27 @@
# sTodo
Stuce's simple todo is a web app that let's you self host a simple todolist.
The goal is to provide a minimalistic and fast todo list that is self hostable.
## Version 1.0.0
Simple todo list for **single user only** at the moment.
## Usage
- can be used as is for a single user behind a vpn (for ex, wireguard)
- can be setup for multi user with a reverse proxy and an authentification provider that supports trusted sso (for ex, nginx + authelia)
## Next goals
- Make multi user support
- [ ] write a minimal step by step guide to install with nix,
- [ ] add some css to make it look nicer
- [ ] add htmx to make more agreable without making js manadatory
- [x] make api to allow usage with native app (a way to get every list that has been modified since date $date belonging from the user in a json or similar format)
- [ ] use getRep and provideRep to make text/javascript response as alternatives to html
- [ ] document api to help create clients
## Version 0.0.3
Simple todo list webapp.
Features :
- add and delete groups that contain a list of todolists
- add and delete todolists inside groups
- add todolist items or edit complete list via text for easy manipulation
- api to allow creating native clients with offline capability
- possibility to deploy easily via nix module with a flake
- that's it, the goal is to keep it minimal !!!
# Development
## Haskell Setup
1. If you haven't already, [install Stack](https://haskell-lang.org/get-started)

1
client_session_key.aes Normal file
View file

@ -0,0 +1 @@
:M^¦ÄO2«I†èš³4,pe<70>¿Ôì8Ä·,CI†(q¸¦7/ ÷gGˆuâÍ×¥L'¿(Éœ¡wq1I#ÔpµÌYW»)2L{2—;våÇ_ižËÀ[ÛÈʳ<C38A>ûÕY

View file

@ -0,0 +1 @@
xdHB>ט<>“,סrMםE®vף×ֶתה`’₪<13>e״<?ל<>ֺ q־P§¶p€$ֶ¢d<>ֺR»ס׳©אױ´sR<73>Mk~¥ auָz|‚ט₪>“\hםuQׁכ¯R4­Q

View file

@ -8,13 +8,15 @@ TodolistItem
Todolist
groupId GroupId OnDeleteCascade
title Text
lastModified UTCTime
UniqueListPair groupId title
User
name Text
lastModified UTCTime
UniqueName name
Group
group Text
lastModified UTCTime
GroupUser
user UserId
group Text
groupId GroupId OnDeleteCascade
groupId GroupId OnDeleteCascade

View file

@ -6,7 +6,7 @@
/favicon.ico FaviconR GET
/robots.txt RobotsR GET
/ HomeR GET
/ GroupR GET
/group/#GroupId TodolistR GET
/add AddGroupR POST
@ -16,10 +16,14 @@
/check/group/#GroupId/todolist/#TodolistId/#TodolistItemId CheckTodolistItemR POST
/edit/group/#GroupId/todolist/#TodolistId EditTodolistItemsR GET POST
/sort/group/#GroupId/todolist/#TodolistId SortTodolistItemsR POST
/trim/group/#GroupId/todolist/#TodolistId TrimTodolistItemsR POST
/edit/group/#GroupId EditTodolistR GET POST
/adduser/group/#GroupId AddUserR POST
/edit EditGroupR GET POST
/delete DeleteGroupR POST
/delete/group/#GroupId DeleteTodolistR POST
/api/#Int ApiR GET

View file

@ -2,6 +2,7 @@
# See https://github.com/yesodweb/yesod/wiki/Configuration#overriding-configuration-values-with-environment-variables
static-dir: "_env:YESOD_STATIC_DIR:static"
session-key: "_env:YESOD_SESSION_KEY:config/client_session_key"
host: "_env:YESOD_HOST:*4" # any IPv4 host
port: "_env:YESOD_PORT:3000" # NB: The port `yesod devel` uses is distinct from this value. Set the `yesod devel` port from the command line.
# For `keter` user, enable the follwing line, and comment out previous one.

125
flake.nix Normal file
View file

@ -0,0 +1,125 @@
{
description = "A flake to install sTodo";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = {
self,
nixpkgs,
}: let
tarball = fetchTarball {
url = "https://git.stuce.ch/stuce/sTodo/releases/download/r3/r3.tar.gz";
sha256 = "1imgbbgbgx2r8qr90mpxlwfy9hcfpdz0sa4nir05jhqx8q1rl0y1";
};
in {
packages.x86_64-linux.sTodo = with nixpkgs.legacyPackages.x86_64-linux;
stdenv.mkDerivation {
pname = "sTodo";
version = "1.0.0";
src = tarball;
buildInputs = [libz gmp libffi];
nativeBuildInputs = [openssl];
installPhase = ''
mkdir -p $out/bin
cp $src/sTodo $out/bin
chmod ugo+x $out/bin/sTodo
'';
mainProgram = "sTodo";
};
nixosModules.sTodo = {
config,
lib,
pkgs,
...
}: let
cfg = config.services.sTodo;
sessionKey = "/etc/sTodo/client_session_key.aes";
in {
options.services.sTodo = {
# options.programs.sTodo = {
enable = lib.mkEnableOption "sTodo";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.x86_64-linux.sTodo;
};
appRoot = lib.mkOption {
type = lib.types.str;
default = "http://localhost:6901";
description = "Link used to access the webapp";
};
clientSessionKey = lib.mkOption {
type = lib.types.str;
default = "/etc/sTodo/client_session_key.aes";
description = "Location of the client session key";
};
port = lib.mkOption {
type = lib.types.int;
default = 6901;
description = "Default port of the app";
};
};
# Systemd Service
config = lib.mkIf cfg.enable {
environment.etc."sTodo/static" = {
source = "${tarball}/static";
};
environment.systemPackages = [pkgs.openssl];
users.groups."sTodo".name = "sTodo";
users.users."sTodo" = {
name = "sTodo";
isSystemUser = true;
group = "sTodo";
};
systemd.services.sTodo.preStart = ''
[ -f ${sessionKey} ] || {
"${pkgs.openssl}/bin/openssl" rand 256 > ${sessionKey}
}
'';
systemd.services.sTodo = {
description = "Launch a sTodo app to have a online todolist";
after = ["network.target"];
wantedBy = ["multi-user.target"];
serviceConfig = {
ExecStart = "${cfg.package}/bin/sTodo";
Restart = "always";
User = "sTodo";
Group = "sTodo";
StateDirectory = "sTodo";
StateDirectoryMode = "0700";
AmbientCapabilities = "";
CapabilityBoundingSet = "";
DeviceAllow = "";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = "read-only";
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "noaccess";
ProtectSystem = "strict";
};
environment = {
YESOD_PORT = "${toString cfg.port}";
YESOD_APPROOT = "${cfg.appRoot}";
YESOD_SQLITE_DATABASE = "/var/lib/sTodo/sTodo.sqlite3";
YESOD_STATIC_DIR = "/etc/sTodo/static";
YESOD_SESSION_KEY = sessionKey;
};
};
};
};
};
}

11
shell.nix Normal file
View file

@ -0,0 +1,11 @@
{pkgs ? import <nixpkgs> {}}:
pkgs.mkShell {
buildInputs = [
pkgs.haskellPackages.ghc
pkgs.haskellPackages.stack
pkgs.haskellPackages.yesod
pkgs.haskellPackages.yesod-bin
pkgs.haskellPackages.haskell-language-server
pkgs.zlib
];
}

View file

@ -1,11 +1,14 @@
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE RecordWildCards #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE InstanceSigs #-}
module Application
( getApplicationDev
, appMain
@ -41,8 +44,10 @@ import System.Log.FastLogger (defaultBufSize, newStdoutLoggerSet,
-- Import all relevant handler modules here.
-- Don't forget to add new modules to your cabal file!
import Handler.Common
import Handler.Home
import Handler.TodoEntry
import Handler.Group
import Handler.Todolist
import Handler.TodolistItem
import Handler.Api
-- This line actually creates our YesodDispatch instance. It is the second half
-- of the call to mkYesodData which occurs in Foundation.hs. Please see the
@ -186,4 +191,4 @@ handler h = getAppSettings >>= makeFoundation >>= flip unsafeHandler h
-- | Run DB queries
db :: ReaderT SqlBackend Handler a -> IO a
db = handler . runDB
db = handler . runDB

View file

@ -1,49 +1,53 @@
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE ExplicitForAll #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE ExplicitForAll #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE NoImplicitPrelude #-}
module Foundation where
import Import.NoFoundation
import Data.Kind (Type)
import Database.Persist.Sql (ConnectionPool, runSqlPool)
import Text.Hamlet (hamletFile)
import Text.Jasmine (minifym)
import Control.Monad.Logger (LogSource)
import Data.Kind (Type)
import Data.Text qualified as Text (intercalate, pack)
import Database.Persist.Sql (ConnectionPool, rawSql, runSqlPool)
import Import.NoFoundation
import Text.Hamlet (hamletFile)
import Text.Jasmine (minifym)
import Yesod.Default.Util (addStaticContentExternal)
import Yesod.Core.Types (Logger)
import qualified Yesod.Core.Unsafe as Unsafe
import qualified Data.CaseInsensitive as CI
import qualified Data.Text.Encoding as TE
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
import Yesod.Core.Types (Logger)
import Yesod.Core.Unsafe qualified as Unsafe
import Yesod.Default.Util (addStaticContentExternal)
-- | The foundation datatype for your application. This can be a good place to
-- keep settings and values requiring initialization before your application
-- starts running, such as database connections. Every handler will have
-- access to the data present here.
{- | The foundation datatype for your application. This can be a good place to
keep settings and values requiring initialization before your application
starts running, such as database connections. Every handler will have
access to the data present here.
-}
data App = App
{ appSettings :: AppSettings
, appStatic :: Static -- ^ Settings for static file serving.
, appConnPool :: ConnectionPool -- ^ Database connection pool.
, appHttpManager :: Manager
, appLogger :: Logger
}
{ appSettings :: AppSettings
, appStatic :: Static
-- ^ Settings for static file serving.
, appConnPool :: ConnectionPool
-- ^ Database connection pool.
, appHttpManager :: Manager
, appLogger :: Logger
}
data MenuItem = MenuItem
{ menuItemLabel :: Text
, menuItemRoute :: Route App
, menuItemAccessCallback :: Bool
}
{ menuItemLabel :: Text
, menuItemRoute :: Route App
, menuItemAccessCallback :: Bool
}
data MenuTypes
= NavbarLeft MenuItem
| NavbarRight MenuItem
= NavbarLeft MenuItem
| NavbarRight MenuItem
-- This is where we define all of the routes in our application. For a full
-- explanation of the syntax, please see:
@ -63,123 +67,128 @@ mkYesodData "App" $(parseRoutesFile "config/routes.yesodroutes")
type Form x = Html -> MForm (HandlerFor App) (FormResult x, Widget)
-- | A convenient synonym for database access functions.
type DB a = forall (m :: Type -> Type).
(MonadUnliftIO m) => ReaderT SqlBackend m a
type DB a =
forall (m :: Type -> Type).
(MonadUnliftIO m) =>
ReaderT SqlBackend m a
-- Please see the documentation for the Yesod typeclass. There are a number
-- of settings which can be configured by overriding methods here.
instance Yesod App where
-- Controls the base of generated URLs. For more information on modifying,
-- see: https://github.com/yesodweb/yesod/wiki/Overriding-approot
approot :: Approot App
approot = ApprootRequest $ \app req ->
case appRoot $ appSettings app of
Nothing -> getApprootText guessApproot app req
Just root -> root
-- Controls the base of generated URLs. For more information on modifying,
-- see: https://github.com/yesodweb/yesod/wiki/Overriding-approot
approot :: Approot App
approot = ApprootRequest $ \app req ->
case appRoot $ appSettings app of
Nothing -> getApprootText guessApproot app req
Just root -> root
-- Store session data on the client in encrypted cookies,
-- default session idle timeout is 120 minutes
makeSessionBackend :: App -> IO (Maybe SessionBackend)
makeSessionBackend _ = Just <$> defaultClientSessionBackend
120 -- timeout in minutes
"config/client_session_key.aes"
-- -- Store session data on the client in encrypted cookies,
-- -- default session idle timeout is 120 minutes
-- makeSessionBackend :: App -> IO (Maybe SessionBackend)
-- makeSessionBackend app = Just <$> defaultClientSessionBackend
-- 120 -- timeout in minutes
-- (appSessionKey $ appSettings app)
-- Yesod Middleware allows you to run code before and after each handler function.
-- The defaultYesodMiddleware adds the response header "Vary: Accept, Accept-Language" and performs authorization checks.
-- Some users may also want to add the defaultCsrfMiddleware, which:
-- a) Sets a cookie with a CSRF token in it.
-- b) Validates that incoming write requests include that token in either a header or POST parameter.
-- To add it, chain it together with the defaultMiddleware: yesodMiddleware = defaultYesodMiddleware . defaultCsrfMiddleware
-- For details, see the CSRF documentation in the Yesod.Core.Handler module of the yesod-core package.
yesodMiddleware :: ToTypedContent res => Handler res -> Handler res
yesodMiddleware = defaultYesodMiddleware . defaultCsrfMiddleware
-- Yesod Middleware allows you to run code before and after each handler function.
-- The defaultYesodMiddleware adds the response header "Vary: Accept, Accept-Language" and performs authorization checks.
-- Some users may also want to add the defaultCsrfMiddleware, which:
-- a) Sets a cookie with a CSRF token in it.
-- b) Validates that incoming write requests include that token in either a header or POST parameter.
-- To add it, chain it together with the defaultMiddleware: yesodMiddleware = defaultYesodMiddleware . defaultCsrfMiddleware
-- For details, see the CSRF documentation in the Yesod.Core.Handler module of the yesod-core package.
-- yesodMiddleware :: ToTypedContent res => Handler res -> Handler res
-- yesodMiddleware = defaultYesodMiddleware . defaultCsrfMiddleware
defaultLayout :: Widget -> Handler Html
defaultLayout widget = do
master <- getYesod
mmsg <- getMessage
defaultLayout :: Widget -> Handler Html
defaultLayout widget = do
master <- getYesod
mmsg <- getMessage
mcurrentRoute <- getCurrentRoute
-- mcurrentRoute <- getCurrentRoute
-- 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.
-- 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.
pc <- widgetToPageContent $ do
$(widgetFile "default-layout")
withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet")
pc <- widgetToPageContent $ do
$(widgetFile "default-layout")
withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet")
isAuthorized
:: Route App -- ^ The route the user is visiting.
-> Bool -- ^ Whether or not this is a "write" request.
-> Handler AuthResult
-- Routes not requiring authentication.
-- TODO: check this bullshit if need to change it or not (prolly authelia problem)
isAuthorized _ _ = return Authorized
-- isAuthorized
-- :: Route App -- ^ The route the user is visiting.
-- -> Bool -- ^ Whether or not this is a "write" request.
-- -> Handler AuthResult
-- -- Routes not requiring authentication.
-- isAuthorized _ _ = return Authorized
-- 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 ::
Text ->
-- \^ The file extension
Text ->
-- \^ The MIME content type
LByteString ->
-- \^ The contents of the file
Handler (Maybe (Either Text (Route App, [(Text, Text)])))
addStaticContent ext mime content = do
master <- getYesod
let staticDir = appStaticDir $ appSettings master
addStaticContentExternal
minifym
genFileName
staticDir
(StaticR . flip StaticRoute [])
ext
mime
content
where
-- Generate a unique filename based on the content itself
genFileName lbs = "autogen-" ++ base64md5 lbs
-- 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
:: Text -- ^ The file extension
-> Text -- ^ The MIME content type
-> LByteString -- ^ The contents of the file
-> Handler (Maybe (Either Text (Route App, [(Text, Text)])))
addStaticContent ext mime content = do
master <- getYesod
let staticDir = appStaticDir $ appSettings master
addStaticContentExternal
minifym
genFileName
staticDir
(StaticR . flip StaticRoute [])
ext
mime
content
where
-- Generate a unique filename based on the content itself
genFileName lbs = "autogen-" ++ base64md5 lbs
-- What messages should be logged. The following includes all messages when
-- in development, and warnings and errors in production.
shouldLogIO :: App -> LogSource -> LogLevel -> IO Bool
shouldLogIO app _source level =
return
$ appShouldLogAll (appSettings app)
|| level
== LevelWarn
|| level
== LevelError
-- What messages should be logged. The following includes all messages when
-- in development, and warnings and errors in production.
shouldLogIO :: App -> LogSource -> LogLevel -> IO Bool
shouldLogIO app _source level =
return $
appShouldLogAll (appSettings app)
|| level == LevelWarn
|| level == LevelError
makeLogger :: App -> IO Logger
makeLogger = return . appLogger
makeLogger :: App -> IO Logger
makeLogger = return . appLogger
-- How to run database actions.
instance YesodPersist App where
type YesodPersistBackend App = SqlBackend
runDB :: SqlPersistT Handler a -> Handler a
runDB action = do
master <- getYesod
runSqlPool action $ appConnPool master
type YesodPersistBackend App = SqlBackend
runDB :: SqlPersistT Handler a -> Handler a
runDB action = do
master <- getYesod
runSqlPool action $ appConnPool master
instance YesodPersistRunner App where
getDBRunner :: Handler (DBRunner App, Handler ())
getDBRunner = defaultGetDBRunner appConnPool
getDBRunner :: Handler (DBRunner App, Handler ())
getDBRunner = defaultGetDBRunner appConnPool
-- This instance is required to use forms. You can modify renderMessage to
-- achieve customized and internationalized form validation messages.
instance RenderMessage App FormMessage where
renderMessage :: App -> [Lang] -> FormMessage -> Text
renderMessage _ _ = defaultFormMessage
renderMessage :: App -> [Lang] -> FormMessage -> Text
renderMessage _ _ = defaultFormMessage
-- Useful when writing code that is re-usable outside of the Handler context.
-- An example is background jobs that send email.
-- This can also be useful for writing code that works across multiple Yesod applications.
instance HasHttpManager App where
getHttpManager :: App -> Manager
getHttpManager = appHttpManager
getHttpManager :: App -> Manager
getHttpManager = appHttpManager
unsafeHandler :: App -> Handler a -> IO a
unsafeHandler = Unsafe.fakeHandlerGetLogger appLogger
@ -191,3 +200,50 @@ unsafeHandler = Unsafe.fakeHandlerGetLogger appLogger
-- https://github.com/yesodweb/yesod/wiki/Sending-email
-- https://github.com/yesodweb/yesod/wiki/Serve-static-files-from-a-separate-domain
-- https://github.com/yesodweb/yesod/wiki/i18n-messages-in-the-scaffolding
-- TODO: complete implementation should short circuit if multi user is on but no user exist, to enforce policy safely
getUserId :: HandlerFor App (Key User)
getUserId = do
mName <- lookupHeader "Remote-User"
currentTime <- liftIO getCurrentTime
mUser <- case mName of
-- TODO: make remote user an argument to make it usable not only with authelia, and maybe do a check for good mesure when nothing is found ?
Just name -> runDB $ insertBy $ User (decodeUtf8 name) currentTime
Nothing -> runDB $ insertBy $ User "single-user" currentTime
case mUser of
Left (Entity userId _) -> return userId
Right userId -> return userId
dbIfAuth :: GroupId -> ReaderT SqlBackend (HandlerFor App) b -> HandlerFor App b
dbIfAuth groupId action = do
-- TODO: decide if we prefer fast (rawSql) or safe (type safe persist query) after in production latency tests
user <- getUserId
result <- runDB $ selectFirst [GroupUserUser ==. user, GroupUserGroupId ==. groupId] []
if isNothing result
then permissionDenied "you are not logged in or you dont have access to this group"
else runDB action
getGroups :: Key User -> Handler [Entity Group]
getGroups userId =
let sql = "SELECT ?? FROM \"group\" JOIN group_user gu ON \"group\".id = gu.group_id WHERE gu.user = ?"
in runDB $ rawSql sql [toPersistValue userId]
todolistItemToCSV :: Entity TodolistItem -> Text
todolistItemToCSV item = "i," <> fieldToText item
todolistToCSV :: Entity Todolist -> Text
todolistToCSV list = "l," <> fieldToText list
groupToCSV :: Entity Group -> Text
groupToCSV group = "g," <> fieldToText group
userToCSV :: Entity User -> Text
userToCSV user = "u," <> fieldToText user
-- TODO: error management ? (maybe use Either Text Text and then propagate left to handler and send error ?)
fieldToText :: (PersistEntity record) => Entity record -> Text
fieldToText field = Text.intercalate "," (map persistValueToText $ entityValues field)
persistValueToText :: PersistValue -> Text
persistValueToText (PersistText s) = s
persistValueToText (PersistInt64 i) = Text.pack $ show i
persistValueToText (PersistUTCTime d) = Text.pack $ show $ floor (utcTimeToPOSIXSeconds d)
persistValueToText (PersistBool b) = if b then "T" else "F"
persistValueToText _ = error "Wrong input type"

33
src/Handler/Api.hs Normal file
View file

@ -0,0 +1,33 @@
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoImplicitPrelude #-}
module Handler.Api where
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import Database.Persist.Sql (rawSql)
import Import
getApiR :: Int -> Handler TypedContent
getApiR time = do
-- TODO: use only one runDB (or use joins ?)
userId <- getUserId
let utcTime = posixSecondsToUTCTime (fromIntegral time)
-- condition : parent user or group changed
let sqlUpdatedGroups = "select ?? from \"group\" join group_user gu on \"group\".id = gu.group_id where gu.user = ? where \"group\".last_modified > ? or user.last_modified > ?;"
-- condition : parent group or list changed
let sqlUpdatedLists = "select ?? from todolist join group_user on todolist.group_id = group_user.group_id and group_user.user = ? join \"group\" as g on g.id = todolist.id where list.last_modified > ? or g.last_modified > ?;"
-- condition : parent list changed
let sqlUpdatedItems = "select ?? from todolist where todolist.last_modified > ? join group_user on todolist.group_id = group_user.group_id and group_user.user = ? join todolist_item on todolist_item.todolist_id = todolist.id;"
runDB $ do
user <- selectList [UserId ==. userId, UserLastModified >. utcTime] []
groups <- rawSql sqlUpdatedGroups [toPersistValue userId, toPersistValue utcTime, toPersistValue utcTime]
lists <- rawSql sqlUpdatedLists [toPersistValue userId, toPersistValue utcTime, toPersistValue utcTime]
items <- rawSql sqlUpdatedItems [toPersistValue utcTime, toPersistValue userId]
let t =
unlines
$ map userToCSV user
<> map groupToCSV groups
<> map todolistToCSV lists
<> map todolistItemToCSV items
return $ TypedContent typePlain $ toContent t

73
src/Handler/Group.hs Normal file
View file

@ -0,0 +1,73 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
module Handler.Group where
import Database.Persist.Sql (fromSqlKey, toSqlKey)
import Import
import Text.Read
getGroupR :: Handler TypedContent
getGroupR = do
userId <- getUserId
groups <- getGroups userId
selectRep $ do
provideRep
$ defaultLayout
$ do
setTitle "Groups"
$(widgetFile "group")
provideRep $ return $ unlines $ map groupToCSV groups
postAddGroupR :: Handler TypedContent
postAddGroupR = do
g <- runInputPost $ ireq textField "group"
-- TODO: in a newer version, put insertUnique_
user <- getUserId
_ <- runDB $ do
currentTime <- liftIO getCurrentTime
gId <- insert $ Group g currentTime
success <- insertUnique $ GroupUser user gId
when (isNothing success) $ delete gId
redirect GroupR
getEditGroupR :: Handler Html
getEditGroupR = do
userId <- getUserId
groups <- getGroups userId
defaultLayout $ do
let a e = pack $ show $ fromSqlKey $ entityKey e :: Text
setTitle "Groups"
$(widgetFile "edit-group")
postEditGroupR :: Handler TypedContent
postEditGroupR = do
-- TODO: not implemented yet
-- title <- runInputPost $ ireq textField "title"
-- users <- runInputPost $ ireq textField "users"
-- id <- runInputPost $ ireq intField "id"
-- let key = toSqlKey id
-- runDB $ update key [GroupGroup =. title]
redirect EditGroupR
postDeleteGroupR :: Handler Html
postDeleteGroupR = do
text <- lookupPostParams "ids"
let ints = map (read . unpack) text :: [Int64]
let ids = map toSqlKey ints :: [GroupId]
userId <- getUserId
currentTime <- liftIO getCurrentTime
-- TODO: test this and maybe change it to sql to be more efficient ?
when (ids /= [])
$ runDB
$ do
update userId [UserLastModified =. currentTime]
deleteWhere [GroupUserGroupId <-. ids, GroupUserUser ==. userId]
nonOrphans <- selectList [GroupUserGroupId <-. ids] []
let nonOrphansIds = map (groupUserGroupId . entityVal) nonOrphans
deleteWhere [GroupId <-. ids, GroupId /<-. nonOrphansIds]
redirect EditGroupR

View file

@ -1,249 +0,0 @@
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE FlexibleContexts #-}
module Handler.TodoEntry where
import Import
import Text.Read
import Database.Persist.Sql (rawExecute, fromSqlKey, toSqlKey)
-- TODO: move this back to another handler
getHomeR :: Handler Html
getHomeR = do
user <- getUserId
groups <- runDB $ do
selectList [GroupUserUser ==. user] [Asc GroupUserGroup]
mToken <- fmap reqToken getRequest
defaultLayout $ do
setTitle "Groups"
[whamlet|
<a href=@{HomeR}>Home
<ul>
$forall group <- groups
<li>
<a href=@{TodolistR $ (groupUserGroupId . entityVal) group}>#{(groupUserGroup . entityVal) group}
<form action=@{AddGroupR} method="post">
<input type="text" name="group" placeholder="new group">
$maybe token <- mToken
<input type="hidden" name="_token" value="#{token}">
<button type="submit">add
<a href=@{EditGroupR}>Edit
|]
postAddGroupR :: Handler Html
postAddGroupR = do
g <- runInputPost $ ireq textField "group"
-- TODO: in a newer version, put insertUnique_
user <- getUserId
_ <- runDB $ do
gId <- insert $ Group g
success <- insertUnique $ GroupUser user g gId
when (isNothing success) $ delete gId
redirect HomeR
postAddTodolistR :: GroupId -> Handler Html
postAddTodolistR groupId = do
list <- runInputPost $ ireq textField "list"
-- TODO: in a newer version, put insertUnique_
_ <- dbIfAuth groupId (insertUnique $ Todolist groupId list)
redirect $ TodolistR groupId
-- TODO: move this to a new handler file
getTodolistR :: GroupId -> Handler Html
getTodolistR groupId = do
lists <- dbIfAuth groupId (selectList [TodolistGroupId ==. groupId] [])
mToken <- fmap reqToken getRequest
defaultLayout $ do
let getTitle = todolistTitle . entityVal
setTitle "todolist"
[whamlet|
<a href=@{HomeR}>Home
<ul>
$forall list <- lists
<li>
<a href=@{TodolistItemsR groupId (entityKey list)}>#{getTitle list}
<form action=@{AddTodolistR groupId} method="post">
<input type="text" name="list" placeholder="new list">
$maybe token <- mToken
<input type="hidden" name="_token" value="#{token}">
<button type="submit">add
<a href=@{EditTodolistR groupId}>Edit
|]
getTodolistItemsR :: GroupId -> TodolistId -> Handler Html
getTodolistItemsR groupId todolistId = do
items <- dbIfAuth groupId (selectList [TodolistItemTodolistId ==. todolistId] [])
mToken <- fmap reqToken getRequest
defaultLayout $ do
setTitle "items"
[whamlet|
<a href=@{HomeR}>Home
&nbsp;
<a href=@{TodolistR groupId}>Lists
<ul>
$forall item <- items
<li>
<form action=@{CheckTodolistItemR groupId todolistId (entityKey item)} method="POST">
$maybe token <- mToken
<input type="hidden" name="_token" value="#{token}">
<button type="submit">#{getText item}
<form action=@{AddTodolistItemR groupId todolistId} method="post">
<form action=@{AddTodolistItemR groupId todolistId} method="post">
<input type="text" name="item" placeholder="new item">
$maybe token <- mToken
<input type="hidden" name="_token" value="#{token}">
<button type="submit">add
<a href=@{EditTodolistItemsR groupId todolistId}>Edit list
|]
postCheckTodolistItemR :: GroupId -> TodolistId -> TodolistItemId -> Handler Html
postCheckTodolistItemR groupId todolistId todolistItemId = do
dbIfAuth groupId (rawExecute "UPDATE todolist_item SET value = NOT value WHERE id = ?" [toPersistValue todolistItemId])
redirect $ TodolistItemsR groupId todolistId
postAddTodolistItemR :: GroupId -> TodolistId -> Handler Html
postAddTodolistItemR groupId todolistId = do
item <- runInputPost $ ireq textField "item"
_ <- dbIfAuth groupId (insert_ $ TodolistItem todolistId False item)
redirect $ TodolistItemsR groupId todolistId
getEditTodolistItemsR :: GroupId -> TodolistId -> Handler Html
getEditTodolistItemsR groupId todolistId = do
items <- dbIfAuth groupId (selectList [TodolistItemTodolistId ==. todolistId] [])
mToken <- fmap reqToken getRequest
let text = unlines $ map getText items
defaultLayout $ do
setTitle "edit"
[whamlet|
<form action=@{EditTodolistItemsR groupId todolistId} method=POST>
<label for="edit text area">Edit todolist
<br>
<textarea id="edit text area" name=text rows=30 cols=50 placeholder="[x] wake up1&#10;[x] eat&#10;[ ] sleep&#10;[ ] repeat">#{text}
$maybe token <- mToken
<input type="hidden" name="_token" value="#{token}">
<br>
<button type="submit">edit
|]
postEditTodolistItemsR :: GroupId -> TodolistId -> Handler Html
postEditTodolistItemsR groupId todolistId = do
text <- runInputPost $ ireq textField "text"
let xs = getItems text todolistId
dbIfAuth groupId (do
deleteWhere [TodolistItemTodolistId ==. todolistId]
insertMany_ xs)
redirect $ TodolistItemsR groupId todolistId
getEditTodolistR :: GroupId -> Handler Html
getEditTodolistR groupId = do
lists <- runDB $
selectList [TodolistGroupId ==. groupId] [Asc TodolistTitle]
mToken <- fmap reqToken getRequest
defaultLayout $ do
let keyToText e = pack $ show $ fromSqlKey $ entityKey e ::Text
setTitle "Groups"
[whamlet|
<form action=@{DeleteTodolistR groupId} method="POST">
<ul>
$forall list <- lists
<li>
<input type="checkbox" name="ids" value="#{keyToText list}">
<a href="">#{(todolistTitle . entityVal) list}
$maybe token <- mToken
<input type="hidden" name="_token" value="#{token}">
<button type=submit>Delete selected
<a href=@{TodolistR groupId}>Back
|]
postDeleteTodolistR :: GroupId -> Handler Html
postDeleteTodolistR groupId = do
text <- lookupPostParams "ids"
let ints = map (read . unpack) text :: [Int64]
let ids = map toSqlKey ints :: [TodolistId]
dbIfAuth groupId (deleteWhere [TodolistId <-. ids])
redirect $ EditTodolistR groupId
postEditTodolistR :: GroupId -> Handler Html
postEditTodolistR groupId = error "not done yet"
getEditGroupR :: Handler Html
getEditGroupR = do
groups <- runDB $ do
-- TODO: using 404 is just a hack to win time, but next it needs better auth handling
userId <- getBy404 $ UniqueName getUser
selectList [GroupUserUser ==. entityKey userId] [Asc GroupUserGroup]
mToken <- fmap reqToken getRequest
defaultLayout $ do
let a e = pack $ show $ fromSqlKey $ entityKey e ::Text
setTitle "Groups"
[whamlet|
<form action=@{DeleteGroupR} method="POST">
<ul>
$forall group <- groups
<li>
<input type="checkbox" name="ids" value="#{a group}">
<a href="">#{(groupUserGroup . entityVal) group}
$maybe token <- mToken
<input type="hidden" name="_token" value="#{token}">
<button type=submit>Delete selected
<a href=@{HomeR}>Back
|]
postEditGroupR :: Handler Html
postEditGroupR = do
-- TODO: not implemented yet
-- title <- runInputPost $ ireq textField "title"
-- users <- runInputPost $ ireq textField "users"
-- id <- runInputPost $ ireq intField "id"
-- let key = toSqlKey id
-- runDB $ update key [GroupGroup =. title]
redirect EditGroupR
postDeleteGroupR :: Handler Html
postDeleteGroupR = do
text <- lookupPostParams "ids"
let ints = map (read . unpack) text :: [Int64]
let ids = map toSqlKey ints :: [GroupId]
-- TODO: make sure the user has access to it aswell (this only works now for single user)
runDB $ deleteWhere [GroupId <-. ids]
redirect EditGroupR
getText :: Entity TodolistItem -> Text
getText item =
if value then "[x] " <> name
else "[ ] " <> name
where
value = (todolistItemValue . entityVal) item
name = (todolistItemName . entityVal) item
getItems :: Text -> TodolistId -> [TodolistItem]
getItems text todolistId = map read (lines text)
where read line = do
let (d, n) = splitAt 4 line
let
value = case d of
"[x] " -> True
"[ ] " -> False
_ -> error "Invalid status"
name = case n of
"" -> error "empty name"
something -> filter (/= '\r') something
TodolistItem todolistId value name
-- TODO: complete implementation should short circuit if multi user is on but no user exist
getUser = "Stuce" :: Text
getUserId :: Handler (Key User)
getUserId = do
mUser <- runDB $ getBy $ UniqueName getUser
case mUser of
Nothing -> runDB $ insert $ User getUser
Just u -> return $ entityKey u
dbIfAuth groupId action = do
-- TODO: decide if we prefer fast (rawSql) or safe (type safe persist query) after in production latency tests
-- TODO: optimize the persist implementation anyway
user <- getUserId
result <- runDB $ selectFirst [GroupUserUser ==. user, GroupUserGroupId ==. groupId] []
if isNothing result then permissionDenied "you are not logged in or you dont have access to this group"
else runDB action

78
src/Handler/Todolist.hs Normal file
View file

@ -0,0 +1,78 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
{-# HLINT ignore "Replace case with fromMaybe" #-}
module Handler.Todolist where
import Database.Persist.Sql (fromSqlKey, toSqlKey)
import Import
import Text.Read
postAddTodolistR :: GroupId -> Handler Html
postAddTodolistR groupId = do
list <- runInputPost $ ireq textField "list"
-- TODO: in a newer version, put insertUnique_
currentTime <- liftIO getCurrentTime
_ <- dbIfAuth groupId (insertUnique $ Todolist groupId list currentTime)
redirect $ TodolistR groupId
getTodolistR :: GroupId -> Handler TypedContent
getTodolistR groupId = do
lists <- dbIfAuth groupId (selectList [TodolistGroupId ==. groupId] [])
selectRep $ do
provideRep
$ defaultLayout
$ do
let getTitle = todolistTitle . entityVal
setTitle "todolist"
$(widgetFile "todolist")
provideRep $ return $ unlines $ map todolistToCSV lists
getEditTodolistR :: GroupId -> Handler Html
getEditTodolistR groupId = do
lists <-
runDB
$ selectList [TodolistGroupId ==. groupId] [Asc TodolistTitle]
defaultLayout $ do
let keyToText e = pack $ show $ fromSqlKey $ entityKey e :: Text
setTitle "Groups"
$(widgetFile "edit-todolist")
postDeleteTodolistR :: GroupId -> Handler Html
postDeleteTodolistR groupId = do
text <- lookupPostParams "ids"
currentTime <- liftIO getCurrentTime
let ints = map (read . unpack) text :: [Int64]
let ids = map toSqlKey ints :: [TodolistId]
dbIfAuth
groupId
( do
deleteWhere [TodolistId <-. ids]
update groupId [GroupLastModified =. currentTime]
)
redirect $ EditTodolistR groupId
postEditTodolistR :: GroupId -> Handler Html
postEditTodolistR groupId = error "not done yet"
postAddUserR :: GroupId -> Handler Html
postAddUserR groupId = do
user <- runInputPost $ ireq textField "user"
_ <-
dbIfAuth
groupId
( do
mUserId <- getBy $ UniqueName user
case mUserId of
Nothing ->
-- handle error
redirect $ TodolistR groupId
Just userId -> insert $ GroupUser (entityKey userId) groupId
)
redirect $ TodolistR groupId

121
src/Handler/TodolistItem.hs Normal file
View file

@ -0,0 +1,121 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
{-# HLINT ignore "Replace case with fromMaybe" #-}
module Handler.TodolistItem where
import Database.Persist.Sql (rawExecute)
import Import
getTodolistItemsR :: GroupId -> TodolistId -> Handler TypedContent
getTodolistItemsR groupId todolistId = do
mSortOption <- lookupSession "sort"
items <- case mSortOption of
(Just "value") -> dbIfAuth groupId (selectList [TodolistItemTodolistId ==. todolistId] [Desc TodolistItemValue, Asc TodolistItemId])
_ -> dbIfAuth groupId (selectList [TodolistItemTodolistId ==. todolistId] [Asc TodolistItemId])
selectRep $ do
provideRep
$ defaultLayout
$ do
setTitle "items"
$(widgetFile "todolist-items")
provideRep $ return $ unlines $ map todolistItemToCSV items
postCheckTodolistItemR :: GroupId -> TodolistId -> TodolistItemId -> Handler Html
postCheckTodolistItemR groupId todolistId todolistItemId = do
currentTime <- liftIO getCurrentTime
dbIfAuth
groupId
( do
rawExecute "UPDATE todolist_item SET value = NOT value WHERE id = ?" [toPersistValue todolistItemId]
update todolistId [TodolistLastModified =. currentTime]
)
redirect $ TodolistItemsR groupId todolistId
postAddTodolistItemR :: GroupId -> TodolistId -> Handler Html
postAddTodolistItemR groupId todolistId = do
currentTime <- liftIO getCurrentTime
item <- runInputPost $ ireq textField "item"
_ <-
dbIfAuth
groupId
( do
insert_ $ TodolistItem todolistId False item
update todolistId [TodolistLastModified =. currentTime]
)
redirect $ TodolistItemsR groupId todolistId
getEditTodolistItemsR :: GroupId -> TodolistId -> Handler Html
getEditTodolistItemsR groupId todolistId = do
items <- dbIfAuth groupId (selectList [TodolistItemTodolistId ==. todolistId] [])
let text = unlines $ map getText items
defaultLayout $ do
setTitle "edit"
$(widgetFile "edit-todolist-items")
postEditTodolistItemsR :: GroupId -> TodolistId -> Handler Html
postEditTodolistItemsR groupId todolistId = do
currentTime <- liftIO getCurrentTime
mText <- runInputPost $ iopt textField "text"
let xs = case mText of
(Just text) -> getItems text todolistId
Nothing -> [] -- Case statement used to let delete all without error TODO: check if can use flatmap instead ?
dbIfAuth
groupId
( do
deleteWhere [TodolistItemTodolistId ==. todolistId]
insertMany_ xs
update todolistId [TodolistLastModified =. currentTime]
)
redirect $ TodolistItemsR groupId todolistId
postTrimTodolistItemsR :: GroupId -> TodolistId -> Handler Html
postTrimTodolistItemsR groupId todolistId = do
currentTime <- liftIO getCurrentTime
dbIfAuth
groupId
( do
deleteWhere [TodolistItemTodolistId ==. todolistId, TodolistItemValue ==. True]
update todolistId [TodolistLastModified =. currentTime]
)
redirect $ TodolistItemsR groupId todolistId
postSortTodolistItemsR :: GroupId -> TodolistId -> Handler Html
postSortTodolistItemsR groupId todolistId = do
mSession <- lookupSession "sort"
case mSession of
(Just "value") -> setSession "sort" "id"
_ -> setSession "sort" "value"
redirect $ TodolistItemsR groupId todolistId
getText :: Entity TodolistItem -> Text
getText item =
if value
then "[x] " <> name
else "[ ] " <> name
where
value = (todolistItemValue . entityVal) item
name = (todolistItemName . entityVal) item
getItems :: Text -> TodolistId -> [TodolistItem]
getItems text todolistId = map read (lines text)
where
read line = do
let (d, n) = splitAt 4 line
let
value = case d of
"[x] " -> True
"[ ] " -> False
_ -> error "Invalid status"
name = case n of
"" -> error "empty name"
something -> filter (/= '\r') something
TodolistItem todolistId value name

View file

@ -33,7 +33,8 @@ data AppSettings = AppSettings
-- ^ Configuration settings for accessing the database.
, appRoot :: Maybe Text
-- ^ Base for all generated URLs. If @Nothing@, determined
-- from the request headers.
, appSessionKey :: [Char]
-- ^ Where to get the client session key
, appHost :: HostPreference
-- ^ Host/interface the server should bind to.
, appPort :: Int
@ -74,6 +75,7 @@ instance FromJSON AppSettings where
appStaticDir <- o .: "static-dir"
appDatabaseConf <- o .: "database"
appRoot <- o .:? "approot"
appSessionKey <- o .: "session-key"
appHost <- fromString <$> o .: "host"
appPort <- o .: "port"
appIpFromHeader <- o .: "ip-from-header"

View file

@ -49,7 +49,7 @@ packages:
# extra-package-dbs: []
# Control whether we use the GHC we find on the path
# system-ghc: true
system-ghc: true
#
# Require a specific version of Stack, using version ranges
# require-stack-version: -any # Default

View file

@ -1,7 +1,7 @@
# This file was autogenerated by Stack.
# You should not edit this file by hand.
# For more information, please see the documentation at:
# https://docs.haskellstack.org/en/stable/topics/lock_files
# https://docs.haskellstack.org/en/stable/lock_files
packages: []
snapshots:

View file

@ -1,11 +1,10 @@
<!-- Page Contents -->
<div .container>
$maybe msg <- mmsg
<div>#{msg}
^{widget}
$maybe msg <- mmsg
<div>#{msg}
^{widget}
<!-- Footer -->
<footer>
<p>
#{appCopyright $ appSettings master}
<p>
#{appCopyright $ appSettings master}

View file

@ -0,0 +1,8 @@
<form action=@{DeleteGroupR} method="POST">
<ul>
$forall group <- groups
<li>
<input type="checkbox" name="ids" value="#{a group}">
<a href="">#{(groupGroup . entityVal) group}
<button type=submit>Delete selected
<a href=@{GroupR}>Back

View file

@ -0,0 +1,6 @@
<form action=@{EditTodolistItemsR groupId todolistId} method=POST>
<label for="edit text area">Edit todolist
<br>
<textarea id="edit text area" name=text rows=30 cols=50 placeholder="[x] wake up1&#10;[x] eat&#10;[ ] sleep&#10;[ ] repeat">#{text}
<br>
<button type="submit">edit

View file

@ -0,0 +1,8 @@
<form action=@{DeleteTodolistR groupId} method="POST">
<ul>
$forall list <- lists
<li>
<input type="checkbox" name="ids" value="#{keyToText list}">
<a href="">#{(todolistTitle . entityVal) list}
<button type=submit>Delete selected
<a href=@{TodolistR groupId}>Back

9
templates/group.hamlet Normal file
View file

@ -0,0 +1,9 @@
<a href=@{GroupR}>Home
<ul>
$forall group <- groups
<li>
<a href=@{TodolistR $ entityKey group}>#{(groupGroup . entityVal) group}
<form action=@{AddGroupR} method="post">
<input type="text" name="group" placeholder="new group">
<button type="submit">add
<a href=@{EditGroupR}>Edit

View file

@ -1,141 +0,0 @@
<div .masthead>
<div .container>
<div .row>
<h1 .header>
Yesod—a modern framework for blazing fast websites
<h2>
Fast, stable & spiced with great community
<a href="http://www.yesodweb.com/book/" .btn.btn-info.btn-lg>
Read the Book
<div .container>
<!-- Starting
================================================== -->
<div .bs-docs-section>
<div .row>
<div .col-lg-12>
<div .page-header>
<h1 #start>Starting
<p>
Now that you have a working project you should use the
<a href=http://www.yesodweb.com/book/>Yesod book</a> to learn more.
<p>
You can also use this scaffolded site to explore some concepts, and best practices.
<ul .list-group>
<li .list-group-item>
This page was generated by the <tt>#{handlerName}</tt> handler in
<tt>Handler/Home.hs</tt>.
<li .list-group-item>
The <tt>#{handlerName}</tt> handler is set to generate your
site's home screen in the Routes file
<tt>config/routes.yesodroutes
<li .list-group-item>
We can link to other handlers, like the <a href="@{ProfileR}">Profile</a>.
Try it out as an anonymous user and see the access denied.
Then, try to <a href="@{AuthR LoginR}">login</a> with the dummy authentication added
while in development.
<li .list-group-item>
The HTML you are seeing now is actually composed by a number of <em>widgets</em>, #
most of them are brought together by the <tt>defaultLayout</tt> function which #
is defined in the <tt>Foundation.hs</tt> module, and used by <tt>#{handlerName}</tt>. #
All the files for templates and widgets are in <tt>templates</tt>.
<li .list-group-item>
A Widget's Html, Css and Javascript are separated in three files with the
<tt>.hamlet</tt>, <tt>.lucius</tt> and <tt>.julius</tt> extensions.
<li .list-group-item ##{aDomId}>
If you had javascript enabled then you wouldn't be seeing this.
<hr>
<!-- Forms
================================================== -->
<div .bs-docs-section>
<div .row>
<div .col-lg-12>
<div .page-header>
<h1 #forms>Forms
<p>
This is an example of a form. Read the
<a href="http://www.yesodweb.com/book/forms">Forms chapter</a> #
in the yesod book to learn more about them.
<div .row>
<div .col-lg-6>
<div .bs-callout bs-callout-info well>
<form .form-horizontal method=post action=@{HomeR}#forms enctype=#{formEnctype}>
^{formWidget}
<button .btn.btn-primary type="submit">
Upload it!
<div .col-lg-4.col-lg-offset-1>
<div .bs-callout.bs-callout-info.upload-response>
$maybe (FileForm info con) <- submission
Your file type is <em>#{fileContentType info}</em>. You say it has: <em>#{con}</em>
$nothing
File upload result will be here...
<hr>
<!-- JSON
================================================== -->
<div .bs-docs-section>
<div .row>
<div .col-lg-12>
<div .page-header>
<h1 #json>JSON
<p>
Yesod has JSON support baked-in.
The form below makes an AJAX request with Javascript,
then updates the page with your submission.
(see <tt>Handler/Comment.hs</tt>, <tt>templates/homepage.julius</tt>,
and <tt>Handler/Home.hs</tt> for the implementation).
<div .row>
<div .col-lg-6>
<div .bs-callout.bs-callout-info.well>
<form .form-horizontal ##{commentFormId}>
<div .field>
<textarea rows="2" ##{commentTextareaId} placeholder="Your comment here..." required></textarea>
<button .btn.btn-primary type=submit>
Create comment
<div .col-lg-4.col-lg-offset-1>
<div .bs-callout.bs-callout-info>
<small>
Your comments will appear here. You can also open the
console log to see the raw response from the server.
<ul ##{commentListId}>
$forall comment <- allComments
<li>#{commentMessage $ entityVal comment}
<hr>
<!-- Testing
================================================== -->
<div .bs-docs-section>
<div .row>
<div .col-lg-12>
<div .page-header>
<h1 #test>Testing
<p>
And last but not least, Testing. In <tt>test/Spec.hs</tt> you will find a #
test suite that performs tests on this page.
<p>
You can run your tests by doing: <code>stack test</code>

View file

@ -1,34 +0,0 @@
document.getElementById(#{toJSON aDomId}).innerHTML = "This text was added by the Javascript part of the homepage widget.";
$(function() {
$("##{rawJS commentFormId}").submit(function(event) {
event.preventDefault();
var message = $("##{rawJS commentTextareaId}").val();
// (Browsers that enforce the "required" attribute on the textarea won't see this alert)
if (!message) {
alert("Please fill out the comment form first.");
return;
}
// Make an AJAX request to the server to create a new comment
$.ajax({
url: '@{CommentR}',
type: 'POST',
contentType: "application/json",
data: JSON.stringify({
message: message,
}),
success: function (data) {
var newNode = $("<li></li>");
newNode.text(data.message);
console.log(data);
$("##{rawJS commentListId}").append(newNode);
},
error: function (data) {
console.log("Error creating comment: " + data);
},
});
});
});

View file

@ -1,13 +0,0 @@
h2##{aDomId} {
color: #990
}
li {
line-height: 2em;
font-size: 16px
}
##{commentTextareaId} {
width: 400px;
height: 100px;
}

View file

@ -1,10 +0,0 @@
<div .ui.container>
<h1>
Access granted!
<p>
This page is protected and access is allowed only for authenticated users.
<p>
Your data is protected with us <strong><span class="username">#{userIdent user}</span></strong>!

View file

@ -0,0 +1,17 @@
<a href=@{GroupR}>Home
&nbsp;
<a href=@{TodolistR groupId}>Lists
<ul>
$forall item <- items
<li>
<form action=@{CheckTodolistItemR groupId todolistId (entityKey item)} method="POST">
<button type="submit">#{getText item}
<form action=@{AddTodolistItemR groupId todolistId} method="post">
<form action=@{AddTodolistItemR groupId todolistId} method="post">
<input type="text" name="item" placeholder="new item">
<button type="submit">add
<form action=@{TrimTodolistItemsR groupId todolistId} method="post">
<button type="submit">trim
<form action=@{SortTodolistItemsR groupId todolistId} method="post">
<button type="submit">sort
<a href=@{EditTodolistItemsR groupId todolistId}>Edit

12
templates/todolist.hamlet Normal file
View file

@ -0,0 +1,12 @@
<a href=@{GroupR}>Home
<ul>
$forall list <- lists
<li>
<a href=@{TodolistItemsR groupId (entityKey list)}>#{getTitle list}
<form action=@{AddTodolistR groupId} method="post">
<input type="text" name="list" placeholder="new list">
<button type="submit">add
<form action=@{AddUserR groupId} method="post">
<input type="text" name="user" placeholder="new user">
<button type="submit">share
<a href=@{EditTodolistR groupId}>Edit