Compare commits

...

96 Commits

Author SHA1 Message Date
35b69ef33a
Loosen on dependencies version
All checks were successful
continuous-integration/drone/push Build is passing
2022-07-28 00:47:13 +02:00
46d14c2917
Update dependencies (security)
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2022-07-28 00:14:35 +02:00
3d607ade56 deps: execute bundle update 2022-06-20 23:48:15 +02:00
08a2729ce6
Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
continuous-integration/drone/push Build is passing
2022-04-23 11:30:35 +02:00
d4de167df2
Merge remote-tracking branch 'origin/master'
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2022-04-23 11:28:44 +02:00
29e78b7f52
cookies: add a duration for cookies 2022-04-23 11:27:55 +02:00
5f8afb008e drone: update ruby image
All checks were successful
continuous-integration/drone/push Build is passing
2022-03-27 17:27:39 +02:00
b9bc73b3b8 recalls: add a red pill to remind recalls to do
closes #61
2022-03-27 17:23:31 +02:00
8efe5d3ab3
offset: fix offset generator for yesterday 2022-03-19 11:13:47 +01:00
db9c5cd7a4 prefs: add show full date checkbox
* factorize user prefs cookies
* add checkboxe
2022-02-28 23:47:14 +01:00
76da1fd1a8 systems: fix UserHelper inclusion 2022-02-28 22:56:26 +01:00
1f44afb893 layout: add the full date (today/yesterday) 2022-02-28 22:52:57 +01:00
0485f8131d
prefs: add offset as a user pref 2022-02-26 16:26:46 +01:00
Arthur POULET
ac29e5bb41 preferences: add a page to edit user prefs
closes #63
2022-01-11 05:25:39 +01:00
Arthur POULET
690d41d33f csv: improve csv headers with category 2022-01-11 05:06:47 +01:00
Arthur POULET
fef976ed5c csv: add csv export in /about 2022-01-05 23:02:25 +01:00
c2b5dd96b2
pex: add date of last insert
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-11-13 12:08:33 +01:00
5a24cdb6b7
deps: comment pg because sqlite is default
All checks were successful
continuous-integration/drone/push Build is passing
2021-08-17 13:16:32 +02:00
ab708456ed
drone: fix automated test
All checks were successful
continuous-integration/drone/push Build is passing
2021-08-17 13:14:02 +02:00
f2ad52586f
env: fix scripts env loading 2021-08-17 13:10:53 +02:00
d1fae9bf63
script: improve db migration 2021-08-17 12:52:09 +02:00
fb02993fa3
recap: add xAxis infos for charts dates 2021-08-14 21:57:37 +02:00
b36249ae76
recap: add input to control how much data to fetch 2021-08-11 09:13:53 +02:00
2559c8c7a7
recap: improve recap by limiting amount of data output 2021-08-11 01:26:23 +02:00
fd23535924
pex: fix update form
Some checks reported errors
continuous-integration/drone/push Build was killed
2021-07-03 13:08:27 +02:00
0e26243315
pex: include an autocomplete in pex form
All checks were successful
continuous-integration/drone/push Build is passing
2021-07-03 13:00:35 +02:00
9315c422a0
recalls: add recall warning on recap
All checks were successful
continuous-integration/drone/push Build is passing
2021-07-03 12:43:34 +02:00
08c895f3a5
recalls: add recall link in nav bar 2021-07-03 12:23:44 +02:00
52a8d4b5fe
recalls: add recalls crd 2021-07-03 12:23:00 +02:00
71e9d7c745
achievements: improve error on bad form infos
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-06-26 15:04:21 +02:00
e3377979ed
logout: change method from GET to POST
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
closes #35
2021-06-26 14:43:18 +02:00
6f0877264f
recap: disable the line 'total' by default
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
participates #24
2021-06-26 12:13:57 +02:00
b9bd6674f6
js: fix date of pex validation when no cookies set
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-06-25 23:46:07 +02:00
51c513e358
partials: add partial dynamic dom replacement framework
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
closes #31
2021-06-25 23:40:03 +02:00
fd71187d53
readme: fix readme
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-25 08:45:30 +02:00
aa479693a3
other: minor script & ci update
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-24 22:24:01 +02:00
3523add226
Merge branch 'feature/partials'
All checks were successful
continuous-integration/drone/push Build is passing
closes #49
2021-06-24 22:11:47 +02:00
f6a951f704
userpex: improve error and fix test databases
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2021-06-24 22:10:58 +02:00
531756e3b6
pex: improve pex validation
* fix 0 validations

* fix user_pex json api
2021-06-24 21:39:27 +02:00
7eb4b680a5
pex: improve the way to compute total validations 2021-06-24 21:13:56 +02:00
3d7028e291
forms: make dynamic forms for + and -
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
It still requires a few polishing touches. But it is 100% functional
2021-06-23 23:53:47 +02:00
0f25ed78e0
csrf: fix csrf for api 2021-06-23 23:52:52 +02:00
612cee76a0
js: add an ajax wrapper 2021-06-23 23:52:20 +02:00
a9652a63a9
readme: clarify everything in the doc
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
closes #58
2021-06-23 19:50:35 +02:00
46d2d3c8d1
boot: add dotenv to load env variables 2021-06-23 19:30:23 +02:00
f350c56e9c
user_pex: add user pex json api with tests
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-22 23:51:38 +02:00
1b4fac518e
base: improve directory loading 2021-06-22 21:44:42 +02:00
9cef2c7a59
pex2: add delete and error management
All checks were successful
continuous-integration/drone/push Build is passing
closes #40
2021-06-22 00:53:37 +02:00
9972bff604
pex2: add update api for pex 2021-06-22 00:34:14 +02:00
6572a40a85
test: finish pex2 test coverage
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-21 22:58:41 +02:00
7c6d0307a0
env: minor changes 2021-06-21 22:25:52 +02:00
d8dcd0f1e5
test: increase pex2 coverage 2021-06-21 22:01:22 +02:00
0a89fe1c36
Merge branch 'feature/atests'
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-06-20 00:16:27 +02:00
a2eeff353a
ci: fix ci configuration with env variable
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-20 00:06:12 +02:00
ff1853e01c
ci: add ci configuration for drone
Some checks reported errors
continuous-integration/drone/push Build was killed
2021-06-19 23:51:13 +02:00
548172b55f
test: add basic test coverage for pex2 2021-06-19 22:58:28 +02:00
15a16e0110
test: Add basic test framework and POC 2021-06-19 18:18:25 +02:00
ef61f05607
doc: add more infos on the readme 2021-06-18 23:13:41 +02:00
93a7c2dcf9
doc: add a bit a documentation for boot 2021-06-18 23:13:08 +02:00
575e8450a4
env: create and use a booting verification
* Create a LifePex::BootFramework which is in charge of verifying that
  all of the requirement to start the application are met.
* The boot framework is usable as a DSL that allow making error and
  warnings.
* Ensure we have a database
* Ensure we have enough secret security (somewhat)

closes #51
closes #50
closes #33
2021-06-18 23:02:48 +02:00
a41c22b211
env: fix environement for production
closes #42
2021-06-16 22:02:36 +02:00
0dfd95761a
security: improve session secret security
closes #45
2021-06-16 21:52:30 +02:00
ac32362255
pex: improve - management to avoid bad ux
closes #44

closes #37
2021-06-16 21:20:51 +02:00
237835eb08
security: add csrf protection and fix last update
closes #34
2021-06-16 21:14:49 +02:00
e7f7d6088f
pex: fix default hidden status
closes #32
2021-06-16 20:39:06 +02:00
2d46801e65
api: fix content type for meta api
closes #38
2021-06-16 20:30:24 +02:00
50d56a1803
js: disable jquery because not required yet 2021-06-16 20:27:20 +02:00
e7e3d0ec6c
layout: fix login link integration 2021-06-14 23:19:03 +02:00
a6ed3bf9d8
readme: add dependencies 2021-06-14 23:18:08 +02:00
05e327e47b
keep public/doc empty folder to avoid init errors 2021-06-14 23:17:45 +02:00
be055ad965
readme: update start instruction for bind 2021-06-14 22:06:04 +02:00
1f44a23e4c
typo: fix a error typo 2021-06-14 22:03:28 +02:00
e9c6837cb5
deps: fix puma dependencies 2021-06-14 22:02:37 +02:00
9e0fb6e946
readme: update setup instructions 2021-06-14 21:58:09 +02:00
84e87bd6f8
deps: fix sqlite3 dependencies 2021-06-14 21:56:41 +02:00
73ef6f4bfa
doc: add a french user guide 2021-06-12 13:14:51 +02:00
261b967b8c
Update register flash warning 2021-06-12 12:40:11 +02:00
2cfaf82cb5
layout: add the about page for disconnected users 2021-06-12 12:38:26 +02:00
364ed2f9f2
img: add favicon 2021-06-11 21:40:06 +02:00
6713aefeda
doc: update security doc 2021-06-11 18:04:45 +02:00
595e4189d3
favorite: add bookmark system to highlight favorites
closes #9
2021-06-11 17:55:59 +02:00
8cc5dfc160
doc: add security doc about privacy 2021-06-11 00:34:43 +02:00
11b9ec2ce5
readme: fix typo 2021-06-10 22:29:57 +02:00
0f010caa68
Merge branch 'feature/medals-full'
Add complete achievements integration.

* Adds a full HTML interface
* Interface to create and delete achievements
* Achievements are related to success that must be defined
  with an algorithm in the source code
* Integrate a basic achievements show in the recap

Do not contain:

* JSON API
* Achievement edition
* Computation optimisation
2021-06-10 22:11:26 +02:00
b341ef4469
achievements: add parameters to the index 2021-06-10 22:06:27 +02:00
c9d9ec9a01
achievements: Add deletion 2021-06-10 21:53:43 +02:00
6434587f89
achievements: add a nice form to add new achievements and medals 2021-06-09 21:44:54 +02:00
cc5f37b61b
achievement: add first step to an edition system 2021-06-08 22:48:31 +02:00
205b0cea8e
medals: put achievements into database with users 2021-06-08 22:10:43 +02:00
b34349855e
Improve the DSL way 2021-06-08 21:44:21 +02:00
1010dbdac8
pex: fix and improve default order
closes #23
2021-06-07 21:51:12 +02:00
df505a7c8c
pex: add hidden pex base feature
closes #10
2021-06-07 20:58:26 +02:00
c217fa0f86
security: Fix leak of categories among other users
closes #14
2021-06-07 20:04:02 +02:00
ebabab2586
Improve category by removing capitalization everywhere in the data 2021-06-06 21:31:36 +02:00
ed2573e657
Improve CSS for small devices 2021-06-06 15:50:05 +02:00
99a4c368e9
Add medail and success system 2021-06-06 12:50:56 +02:00
71 changed files with 2525 additions and 325 deletions

13
.drone.yml Normal file
View File

@ -0,0 +1,13 @@
kind: pipeline
name: default
steps:
- name: test
image: ruby:3.1
environment:
LIFEPEX_DB: "sqlite://test.db"
LIFEPEX_ENV: "test"
commands:
- bundle install --jobs=1 --retry=1
- rake db:migrate
- rake test

5
.env.sample Normal file
View File

@ -0,0 +1,5 @@
LIFEPEX_DB=sqlite://sqlite.db
LIFEPEX_BIND=127.0.0.1
LIFEPEX_BASE_URL=
LIFEPEX_SECRET=
PORT=

2
.gitignore vendored
View File

@ -1 +1,3 @@
*.db
coverage
.env.*local

42
Gemfile
View File

@ -2,17 +2,35 @@
source "https://rubygems.org"
gem "slim", "~> 4.1"
gem "pry", "~> 0.14.1"
gem "sequel", "~> 5.43"
gem "bcrypt", "~> 3.1"
gem "sinatra", "~> 2.1"
gem "sinatra-contrib", "~> 2.1"
gem "jwt", "~> 2.2"
# web
gem "puma", "~> 5"
gem "sinatra", "~> 2"
gem "sinatra-contrib", "~> 2"
gem "slim", "~> 4"
gem "pg", "~> 1.2"
# database
gem "sequel", "~> 5"
# you # comment the drivers you don't want
gem "sqlite3", "~> 1"
# gem "pg", "~> 1.2"
gem "colorize", "~> 0.8.1"
gem "doc_my_routes",
:git => "https://github.com/Nephos/doc_my_routes.git",
branch: "master"
# security
gem "jwt", "~> 2"
gem "bcrypt", "~> 3"
gem "rack_csrf", "~> 2"
# api tools
gem "doc_my_routes"
# debug and helpers
gem "colorize", "~> 0.8"
gem "activesupport", "~> 6"
# tests
group :test do
gem "pry"
gem "rack-test", "~> 1", require: false
# gem "simplecov", "~> 0.21.2", require: false
end
gem "dotenv", "~> 2"

View File

@ -1,61 +1,81 @@
GIT
remote: https://github.com/Nephos/doc_my_routes.git
revision: 3325f74615ac254ebf897872593a99f8dfb8a70e
branch: master
specs:
doc_my_routes (0.13.0)
GEM
remote: https://rubygems.org/
specs:
bcrypt (3.1.16)
activesupport (6.1.6.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
bcrypt (3.1.18)
coderay (1.1.3)
colorize (0.8.1)
jwt (2.2.3)
concurrent-ruby (1.1.10)
doc_my_routes (0.13.0)
dotenv (2.8.1)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
jwt (2.4.1)
method_source (1.0.0)
minitest (5.16.2)
multi_json (1.15.0)
mustermann (1.1.1)
mustermann (2.0.2)
ruby2_keywords (~> 0.0.1)
pg (1.2.3)
nio4r (2.5.8)
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
rack (2.2.3)
rack-protection (2.1.0)
puma (5.6.4)
nio4r (~> 2.0)
rack (2.2.4)
rack-protection (2.2.2)
rack
ruby2_keywords (0.0.4)
sequel (5.43.0)
sinatra (2.1.0)
mustermann (~> 1.0)
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack_csrf (2.6.0)
rack (>= 1.1.0)
ruby2_keywords (0.0.5)
sequel (5.58.0)
sinatra (2.2.2)
mustermann (~> 2.0)
rack (~> 2.2)
rack-protection (= 2.1.0)
rack-protection (= 2.2.2)
tilt (~> 2.0)
sinatra-contrib (2.1.0)
sinatra-contrib (2.2.2)
multi_json
mustermann (~> 1.0)
rack-protection (= 2.1.0)
sinatra (= 2.1.0)
mustermann (~> 2.0)
rack-protection (= 2.2.2)
sinatra (= 2.2.2)
tilt (~> 2.0)
slim (4.1.0)
temple (>= 0.7.6, < 0.9)
tilt (>= 2.0.6, < 2.1)
sqlite3 (1.4.4)
temple (0.8.2)
tilt (2.0.10)
tilt (2.0.11)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
zeitwerk (2.6.0)
PLATFORMS
x86_64-linux
DEPENDENCIES
bcrypt (~> 3.1)
colorize (~> 0.8.1)
doc_my_routes!
jwt (~> 2.2)
pg (~> 1.2)
pry (~> 0.14.1)
sequel (~> 5.43)
sinatra (~> 2.1)
sinatra-contrib (~> 2.1)
slim (~> 4.1)
activesupport (~> 6)
bcrypt (~> 3)
colorize (~> 0.8)
doc_my_routes
dotenv (~> 2)
jwt (~> 2)
pry
puma (~> 5)
rack-test (~> 1)
rack_csrf (~> 2)
sequel (~> 5)
sinatra (~> 2)
sinatra-contrib (~> 2)
slim (~> 4)
sqlite3 (~> 1)
BUNDLED WITH
2.2.16

111
README.md
View File

@ -1,50 +1,103 @@
# LifePEx
# LifePex
## Setup
[![Build Status](https://drone.sceptique.eu/api/badges/Sceptique/LifePex/status.svg)](https://drone.sceptique.eu/Sceptique/LifePex)
## Install the software on a server
### Install the dependencies
You should have the following systems installed: `git` `ruby 3 AND gems` `sqlite OR postgresql`
Git is required to clone the project and fetch the tags in order to expose them in the
webapp.
Then make sure you have installed bundler `gem install bundler` if not already done.
Then install the ruby dependencies.
```
# SQLITE
./init/database.rb sqlite://sqlite.db
bundle install
```
# POSTGRESQL
### Configuration setup
A sample file contains all the variables you will need in order to start the server.
This is the `.env.sample` file. You should copy it under the name `.env.local`.
* LIFEPEX_DB: is an url to your database. It may include a user/password
* LIFEPEX_BIND: what your socket will listen to (should probably be 127.0.0.1 or 0.0.0.0)
* LIFEPEX_BASE_URL: base url for the whole app (for local usage you can leave if empty)
* LIFEPEX_SECRET: you must put randomness in this variable to secure tokens and cookies
* LIFEPEX_ENV: may be empty, test, or production to avoid showing the stacktrace when there is a bug
### Database setup
The recommanded way is simply to init the database you configured with
```
./init/database.rb
```
If you have postgresql, you may want to create a specific user/password for this.
Since I suck with SGDB administration, this is a sample of what you may do.
**TODO: show how to create a simple attribute with full permission on this database**
```
psql -U postgres postgres -c "CREATE USER root WITH PASSWORD 'toor' SUPERUSER;"
psql -U postgres postgres -c "CREATE DATABASE life_pex"
./init/database.rb postgres://root:toor@localhost/life_pex
echo LIFEPEX_DB=postgres://root:toor@localhost/life_pex >> .env.local
rake db:migrate
```
## Start
### Start
* configuration: check
* setup dependencies: check
* setup database: check
You can simply start the application.
```
export LIFEPEX_SECRET="put something random here"
export LIFEPEX_BASE_URL="https://mydomain/base"
export LIFEPEX_DB="sqlite://sqlite.db"
# you may also want to use postgres or something
# export LIFEPEX_DB="postgres://root:toor@localhost/life_pex"
./src/app.rb
```
## Generate documentation
### Service, update, etc.
You may want to take a look at the `scripts/` directory which contains tons of config helper, for nginx, systemd, etc.
---
![recap image](https://git.sceptique.eu/attachments/ce7c2fab-bd1b-43fc-b10a-21b2c953e2c2)
![today image](https://git.sceptique.eu/attachments/ecd7a36d-eac7-40aa-b973-c6ddbac87f51)
## Developers
### Generate documentation
```
./init/doc.rb
xdg-open ./public/doc/index.html
```
## Features
### Testing
* [x] Register an account
* [x] Reset password
* [x] Add pex models
* [x] Add point each day to each pex model
* [x] Make a nice smooth level function
* [x] Create a nice CSS template to make it dark and flashy and cyberpunk kikoo
* [x] Protect privacy with private pex models
* [x] Import CSV script
* [x] Make a basic recap graph
* [x] Make a niice recap graph
* [x] Add an easy and quick way to edit pex
* [ ] Add advanced pex with variable value
* [ ] Improve security (token validity limit, random seed warning, ...)
Generate first a specific configuration file
![recap image](https://git.sceptique.eu/attachments/ce7c2fab-bd1b-43fc-b10a-21b2c953e2c2)
![today image](https://git.sceptique.eu/attachments/ecd7a36d-eac7-40aa-b973-c6ddbac87f51)
```
cp .env.local .env.test.local
editor .env.test.local # don't forget to set a new database !!!
```
Then init the database
```
LIFEPEX_ENV=test rake db:migrate
```
Then if you want to run the test, simply type `rake test` (you will need the startup env variable to be set first).
### Debug
Some debug options can be enabled with the env variable
`LIFEPEX_ENV=debug`

21
Rakefile Normal file
View File

@ -0,0 +1,21 @@
require "rake"
require "rake/testtask"
Rake::TestTask.new do |t|
t.pattern = "test/*_test.rb"
end
namespace "db" do
desc "Migrate the database to the lasted schema"
task "migrate" do
require_relative "./init/migrate_db"
end
desc "Reset all tables, schema, data"
task "reset" do
require_relative "./init/load_env"
DB.tables.each {|t| DB.drop_table t }
end
end
task default: :test

3
config.ru Normal file
View File

@ -0,0 +1,3 @@
require_relative "./src/app"
run LifePex::App

94
documentation/GUIDE.FR.md Normal file
View File

@ -0,0 +1,94 @@
# Guide utilisateur (French)
Version du logiciel concernée: v0.15.0.
*Cette version devrait contenir les mêmes fonctionnalités que la v1.0.0. Il n'y aura donc pas besoin de mise à jour pour la v1.0. Les principales évolutions prévues après cela devraient concerner le design de l'application, ce qui necessitera une mise à jour des images inclues.*
## Fonctionnalités principales du logiciel
Le premier but du logiciel est d'offrir une outil pour suivre dans le temps des objectifs. C'est un outil générique, configurable, que l'utilisateur peut adapter à ses besoins. Parmi les usages standards pensés à l'origine on trouve:
* Suivre son avancement dans un programme sportif (lister des objectifs à cocher)
* Ne pas oublier ses erreurs (marquer ses faiblesses pour les surmonter)
* Encourager les comportements positifs (ne pas oublier qu'on réussit des choses)
## License, engagements
LifePex est un logiciel sous license GPLv3, une license dite "libre". Elle ne comporte aucune garantie que le logiciel lui-même est sans faille ni d'un support quelconque. Il est developpé benevolement. Enfin, vous avez le droit de lire le code source, le modifier et le redistribué, sous reserve que cela soit fait sous license GPLv3 (ou compatible).
L'administrateur de l'hebergement de LifePex devrait s'engager à ne jamais accéder aux données personnelles stoquées par les utilisateurs de l'instance sans l'accord explicite et individuel de l'utilisateur concerné. Il devrait également s'engager à s'assurer de la mise à jour du logiciel afin de corriger toute faille corrigée pour protéger les utilisateurs qui lui font confiance.
L'administrateur devrait également s'engager à ne jamais installer de système de traçage de ses utilisateurs. Tout besoin de suivi de charge ou de sécurité ne devrait jamais prendre le pas sur la vie privée de ses utilisateurs.
## Tour du logiciel
LifePex est un logiciel qui a avant tout pour objectif d'être utilisé sur mobile.
Cependant nous faisons attention à ce que toutes les fonctionnalités soient disponible sur mobile et ordinateur.
### 1. Barre de navigation (invité)
Toute page importante du logiciel est accessible via la barre de navigation.
Celle-ci est accessible en cliquant sur le bouton en haut à droite de l'écran.
En temps qu'utilisateur non enregistré vous n'avez pas encore accès à beaucoup de pages.
Cliquez sur s'enregistrer ("Register") pour passer à la suite.
![](https://git.sceptique.eu/attachments/00c81464-06bd-446d-bbb8-f347bc574d2d)
### 2. Enregistrement d'un nouveau compte
La première étape de la création d'un nouveau compte est d'entrer un nom d'utilisateur que vous voulez utiliser pour s'identifier, et un mot de passe que seul vous pouvez connaitre. Cela vous permettra l'empêcher tout autre utilisateur d'accéder à vos données. Le boutton en fin de page permet de valider la création de son compte.
Cela vous connectera automatiquement.
![](https://git.sceptique.eu/attachments/5dc654cd-f8aa-44ea-b7e5-8f3b0bf4eb67)
*Attention: le logiciel ne dispose pas de votre addresse email à ce stade, il est donc impossible de retrouver un mot de passe oublié.*
### 3. Connexion à un compte existant
Si vous avez déjà un compte cependant, vous pouvez également utiliser le bouton de connexion dans la barre de navigation pour accéder à la page correspondante. Entrez simplement votre nom d'utilisateur et mot de passe renseigné lors de la création de votre compte pour accéder à votre compte et validez le formulaire.
![](https://git.sceptique.eu/attachments/85e9b4cb-de9b-4335-ba5b-2f1b94557606)
### 4. Page principale (aujourd'hui)
La page principale vous donne accès à votre tableau de bord. C'est là que vous pouvez valider un point dans une des lignes (parfois ppellées "pex"). Appuyez sur le bouton + ou - de la ligne pour augmenter le nombre de validations de la journée.
![](https://git.sceptique.eu/attachments/ba3fd0b0-1a72-496b-aec5-9fe46541b83f)
#### 4.1 Ajout d'un nouveau pex
Si vous souhaitez ajouter une nouvelle ligne, il est possible d'appuyer sur le gros boutton vert en bas à droit de la page principale. Cela vous amènera sur un formulaire contenant un champs pour le nom, la catégorie, et le nombre de "xp" que chaque validation de ce pex vous apportera. Les xp sont une valeur relative utilisée pour comparer la valeur de 2 pex.
![](https://git.sceptique.eu/attachments/bcf313ec-ee32-4a20-ba86-c2d24ce91426)
### 5. Barre de navigation (avec un compte)
La barre de navigation, toujours accessible via le boutton en haut à droite de l'écran permet maintenant d'accéder à de nombreuses pages.
![](https://git.sceptique.eu/attachments/890c50a1-c6d1-4474-b756-8e6d5cdac76b)
### 6. Récapitulatif
La page de récapitulatif, via la barre de navigation et le boutton "Recap" permet d'avoir accès en 1 coup d'oeil plusieurs informations:
* Votre niveau et votre xp accumulé en se basant sur les "xp" évoqué en #4.1
* Le progrès dans le temps de vos différentes catégories. Il est possible de cliquer sur la légende pour faire apparaitre ou disparaitre une des ligne du graphique.
* Vos accomplissements validés et leurs médailles respectives.
![](https://git.sceptique.eu/attachments/ddc33b93-f660-4991-87f4-7e4e6a382e16)
### 7. Les accomplissements
![](https://git.sceptique.eu/attachments/1803bf3b-633f-49bb-a079-0adf7b12f03c)
### 8. Ajout d'accomplissement
![](https://git.sceptique.eu/attachments/88362fae-0823-4373-9861-82872057a8a0)
### 9. Page principale, options avancées
![](https://git.sceptique.eu/attachments/abf52606-f1b4-45cd-9ca6-53ef7b33c889)

35
documentation/SECURITY.md Normal file
View File

@ -0,0 +1,35 @@
# Security
## List of important things
* Actors
* Users: uses probably a web browser
* Administrator: host data and source code
* Software
* SQL Database: external software that holds data
* Ruby and dependencies: source code from open source external software
* Bootstrap CDN: css and javascript from external source
* Hardware
* Host server
* Sensitive data
* Private user's data
## Trust, privacy, threat model
* 1. Because we handle private data, it is important to protect it against leak.
User's private data should not be exposed publicly, and it should not be
possible to impersonate users.
* 2. Data inputed should not include financial information, making it
less a target for attackers. It should also not include many personnal
information, so even in case of security leak, exploiting data should be harder.
However, it is possible that the user find interest into putting sexual,
religious or medical information in the database. This is might be dangerous
for individuals if they are not aware of the point 3.
* 3. Because of the way it is currently used, the administrator has a complete
technical control over the data which are very easy to extract and export.
The administrator is probably close from some of the users and may have a
interest into looking these easly accessible private data.
The user must consider the administrator trustworthy of the information he
will input or not fear them to be read by the administrator.

13
init/load_env.rb Normal file
View File

@ -0,0 +1,13 @@
require "sequel"
require "colorize"
require_relative "../src/utils/env"
require_relative "../src/utils/semver"
load_dotenv
DB = Sequel.connect ENV["LIFEPEX_DB"]
if ENV["environment"] == "debug"
require "pry"
binding.pry
end

View File

@ -1,38 +1,30 @@
#!/usr/bin/env ruby
require "sequel"
require "colorize"
require_relative "../src/utils/semver.rb"
DB = Sequel.connect ARGV[0]
if ARGV[1] == "debug"
require "pry"
binding.pry
end
require_relative "./load_env"
def current_version
DB[:meta].first[:version] rescue 0
end
def migrate(version, &block)
puts "Check migration #{version}"
def migrate(version, message = nil, &block)
puts "Check migration #{version}".on_blue
if current_version < version
puts "Migrate #{version}".blue
begin
yield
DB[:meta].update(version: version)
puts "Successfuly set version #{version}"
puts "Successfuly set version #{version}".green
puts message.green if message
rescue => err
puts err.message.red
puts err.message.on_red
exit 1
end
else
puts "Already migrated #{version}".blue
puts "Already migrated #{version}".yellow
end
end
migrate 1 do
migrate 1, "Initialized database" do
DB.create_table :meta do
primary_key :id
Int :version
@ -57,33 +49,29 @@ migrate 1 do
String :username
String :hashed_password
end rescue puts "users already exists".yellow
puts "Initialized database".green
end
migrate 2 do
migrate 2, "Add pex categories" do
DB.alter_table :pexs do
add_column :category, String
end rescue puts "pexs.category already exists".yellow
puts "Migrated categories".green
end
migrate 3 do
migrate 3, "Add pex user belonging" do
DB.alter_table :pexs do
add_column :user_id, :Int
end rescue puts "pexs.user_id already exists".yellow
DB[:pexs].update(user_id: 1)
puts "Migrated pex belonging to user".green
end
migrate 4 do
migrate 4, "Fix pex category default behavior" do
DB.alter_table :pexs do
set_column_default(:category, '')
end rescue puts "pexs.category default already exists".yellow
DB[:pexs].where(category: nil).update(category: '')
puts "Migrated pex default category".green
end
migrate 5 do
migrate 5, "Add meta schema" do
DB.alter_table :meta do
add_column :code_version, String
end rescue puts "meta.code_version already exists".yellow
@ -94,10 +82,51 @@ migrate 6 do
DB.alter_table :meta do
add_column :code_date, String
end rescue puts "meta.code_date already exists".yellow
DB[:meta].update(code_version: "0")
end
puts "End migration".green
migrate 7, "Fix pex initial category" do
DB[:pexs].each do |pex|
DB[:pexs].where(id: pex[:id]).update(category: pex[:category].to_s.downcase)
end
end
migrate 8, "Add default hidden for pexs" do
DB.alter_table :pexs do
add_column :hidden, TrueClass
end rescue puts "pex.hidden already exists".yellow
DB[:pexs].each { |pex| DB[:pexs].where(id: pex[:id]).update(hidden: false) }
end
migrate 9, "Add achievements" do
DB.create_table :achievements do
primary_key :id
Int :user_id
String :name
String :success_name
String :parameters_json
String :icon
end
end
migrate 10, "Add generic flag to pexs, for bookmarking" do
DB.alter_table :pexs do
add_column :flag, :String
end rescue puts "pexs.flag already exists".yellow
end
migrate 11, "Add recalls" do
DB.create_table :recalls do
primary_key :id
Int :user_id
Int :pex_id
String :name
Int :span_duration
Int :repeated
end
puts "Migrated recalls".green
end
puts "End migration".on_blue
CODE_VERSION = `git tag`.split("\n").map{ |str| Utils::Semver.new(str) }.sort.last.to_s
CODE_DATE=`git show #{CODE_VERSION} --pretty="format:%as"`.split("\n").first
DB[:meta].update(code_version: CODE_VERSION)

View File

@ -16,7 +16,46 @@
background-color: #333;
}
.bg-bookmarked {
background: #332;
}
.dropdown-menu {
background: #ff00;
border-color: #ff00;
}
/* .dropdown-menu[data-bs-popper] { */
/* left: -100%; */
/* } */
/* @media (min-width: 374px) { */
/* .navbar-expand-xs { */
/* flex-wrap: nowrap; */
/* justify-content: flex-start; */
/* } */
/* .navbar-expand-xs .navbar-nav { */
/* flex-direction: row; */
/* } */
/* .navbar-expand-xs .navbar-nav .dropdown-menu { */
/* position: absolute; */
/* } */
/* .navbar-expand-xs .navbar-nav .nav-link { */
/* padding-right: 0.5rem; */
/* padding-left: 0.5rem; */
/* } */
/* .navbar-expand-xs .navbar-nav-scroll { */
/* overflow: visible; */
/* } */
/* .navbar-expand-xs .navbar-collapse { */
/* display: flex !important; */
/* flex-basis: auto; */
/* } */
/* .navbar-expand-xs .navbar-toggler { */
/* display: none; */
/* } */
/* } */
/* .navbar-nav { */
/* flex-direction: row; */
/* } */

View File

@ -70,6 +70,10 @@ h2 {
height: 45px;
}
.smaller {
font-size: x-small !important;
}
.highcharts-figure, .highcharts-data-table table {
min-width: 360px;
max-width: 800px;

0
public/doc/.keep Normal file
View File

BIN
public/img/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

34
public/js/ajax.js Normal file
View File

@ -0,0 +1,34 @@
async function ajax({
method = "GET",
url = ".",
body = undefined,
headers = {},
on_success = () => {},
on_failure = () => {},
}) {
const xhttp = new XMLHttpRequest();
const return_on_sent = new Promise((resolve, reject) => {
try {
xhttp.onreadystatechange = () => {
if (xhttp.readyState == 4) {
const response_status = String(xhttp.status);
if (/2\d\d/.test(response_status)) {
resolve(on_success(xhttp.responseText, xhttp));
} else {
resolve(on_failure(xhttp.responseText, xhttp));
}
}
};
} catch (err) {
reject(err);
}
});
xhttp.open(method, url, true);
Object.keys(headers).forEach((header_key) => {
xhttp.setRequestHeader(header_key, headers[header_key]);
});
xhttp.send(body);
return return_on_sent;
}

11
public/js/error.js Normal file
View File

@ -0,0 +1,11 @@
function flashError(message) {
const new_flash_error = document.createElement('p');
new_flash_error.classList.add("alert");
new_flash_error.classList.add("alert-danger");
new_flash_error.classList.add("alert-dismissible");
new_flash_error.classList.add("fade");
new_flash_error.classList.add("show");
new_flash_error.setAttribute("role", "alert");
new_flash_error.innerHTML = `${message} <button class=\"btn-close\" type=\"button\" data-bs-dismiss=\"alert\" aria-label=\"Close\" />`;
const flash = document.querySelectorAll('.flash')[0].appendChild(new_flash_error);
}

View File

@ -17,16 +17,99 @@ function toggle(node) {
}
}
document.addEventListener("DOMContentLoaded", (_event) => {
const togglers = document.querySelectorAll('.pex-editor-toggler');
Array.toObject = function (arr) {
return arr.reduce((base, current) => {
base[current[0]] = current[1];
return base;
}, {});
};
togglers.forEach((t) => {
const name = t.attributes.name.value;
function __map__(cb = (e) => e) {
const arr = [];
for (i = 0; i < this.length; i++) {
arr.push(cb(this[i]));
}
return arr;
}
HTMLCollection.prototype.map = __map__;
NodeList.prototype.map = __map__;
NamedNodeMap.prototype.map = __map__;
HTMLInputElement.prototype.attributesObject = function () {
return Array.toObject(this.attributes.map((attr) => [attr.name, attr.value]));
};
HTMLFormElement.prototype.attributesObject = function () {
return Array.toObject(this.attributes.map((attr) => [attr.name, attr.value]));
};
function parseCookie(str) {
return str
.split(";")
.map((v) => v.split("="))
.reduce((acc, v) => {
if (v.length == 2) acc[decodeURIComponent(v[0].trim())] = decodeURIComponent(v[1].trim());
return acc;
}, {});
}
function userpexValidation(event) {
event.preventDefault();
const attributes = event.target.attributesObject();
const fields = event.target.childNodes.map().filter(e => e.nodeName == 'INPUT');
const fields_attributes = Array.toObject(fields.map(attr => [attr.name, attr.value]));
const date = parseCookie(document.cookie).date || new Date().toISOString().slice(0, 10);
ajax({
method: fields_attributes["type"] == "-" ? "DELETE" : "POST",
url: `/api/user-pex/v1/pexs/${fields_attributes["id"]}/validation`,
body: JSON.stringify({ date: date, force_count_total: true }),
headers: { Accept: "application/json", "Content-Type": "application/json" },
on_success: (body) => {
// === Sample how to make dynamic partial replacement ===
// const target_line = event.target.parentNode.parentElement;
// ajax({
// method: 'GET',
// url: `/api/pex/v2/pexs/${fields_attributes["id"]}/more`,
// headers: { Accept: "text/html;*/*" },
// on_success: (body) => {
// target_line.innerHTML = body;
// const toggler = target_line.querySelector('.pex-editor-toggler');
// setupPexEditorToggler(toggler);
// },
// on_failure: (body, req) => {
// flashError("Error JS#0002 while rendering...");
// },
// });
const json_body = JSON.parse(body);
const text_target = event.target.parentNode.parentElement.childNodes[3];
text_target.textContent = String(json_body.count_total);
const should_hide_decrease_button = json_body.count_total == 0;
event.target.parentNode.parentNode.childNodes[2].childNodes[0].hidden = should_hide_decrease_button;
},
on_failure: (body, req) => {
flashError("Error JS#0001 while validating...");
},
});
}
function setupPexEditorToggler(toggler) {
const name = toggler.attributes.name.value;
const pex_editor = document.querySelector(`.pex-editor[name="${name}"]`);
hide(pex_editor);
show(t);
t.addEventListener("click", (event) => {
show(toggler);
toggler.addEventListener("click", (event) => {
toggle(pex_editor);
});
}
document.addEventListener("DOMContentLoaded", (_event) => {
const editor_togglers = document.querySelectorAll('.pex-editor-toggler');
editor_togglers.forEach(setupPexEditorToggler);
const userpexvalidation0 = document.querySelectorAll('.userpexvalidationvalue').map().filter(tag => tag.textContent == "0");
userpexvalidation0.forEach((tag) => {
tag.parentNode.parentNode.childNodes[2].childNodes[0].hidden = true;
});
});

View File

@ -1,4 +1,4 @@
function setupChart(...cumuls) {
function setupChart({ all_dates }, ...cumuls) {
const chart = Highcharts.chart('recap-xp-container', {
chart: {
backgroundColor: '#000',
@ -7,24 +7,53 @@ function setupChart(...cumuls) {
text: '',
color: '#55f5f5',
},
series: cumuls.map(cumul => ({
xAxis: {
categories: all_dates,
},
series: cumuls.map((cumul, idx) => ({
// color: '#55f5f5',
visible: idx != 0,
name: cumul.name,
data: cumul.data,
})),
});
}
document.addEventListener('DOMContentLoaded', function () {
const xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = () => {
if (xhttp.readyState == 4 && xhttp.status == 200) {
const json_output = JSON.parse(xhttp.responseText);
setupChart(...json_output.pex_tables);
}
};
function objectToQueryParam(obj) {
return Object.entries(obj).map(tuple => tuple.join("=")).join("&");
}
xhttp.open("GET", "/api/pex/v1/recap", true);
xhttp.setRequestHeader("Accept", "application/json");
xhttp.send();
function urlWithQueryParams(base, object_with_params) {
if (Object.keys(object_with_params).length > 0) {
return `${base}?${objectToQueryParam(object_with_params)}`;
}
return base;
}
async function requestChart() {
let url = "/api/pex/v1/recap";
const days_ago = document.querySelector("#inputDaysAgo").value;
if (days_ago.length > 0) {
url = urlWithQueryParams(url, { days_ago });
}
ajax({
method: "GET",
url: url,
body: null,
headers: { Accept: "application/json" },
on_success: (body) => {
const json_output = JSON.parse(body);
setupChart({ all_dates: json_output.all_dates }, ...json_output.pex_tables);
},
});
}
document.addEventListener('DOMContentLoaded', async function () {
requestChart();
const form = document.querySelector(".recap-xp form#reloader");
form.addEventListener("submit", (event) => {
event.preventDefault();
requestChart();
});
});

10
scripts/README.md Normal file
View File

@ -0,0 +1,10 @@
# Scripts
In the scripts we are supposed to do everything as the user and group `lifepex`.
The software is supposed to be cloned in `/srv/LifePex`.
When copying a script, make sure you update the variables.
* `update.sh` pull the lastest source code and ensure the database is migrated
the lastest schema.
* `lifepex.service` is usable for installing a service in systemd.
* `nginx.conf` it is a possible configuration for nginx reverse proxy.

17
scripts/lifepex.service Normal file
View File

@ -0,0 +1,17 @@
[Unit]
Description=LifePex
Documentation=https://git.sceptique.eu/Sceptique/LifePex
[Service]
ExecStart=/usr/bin/env ruby /srv/LifePex/src/app.rb
Restart=on-failure
RestartSec=5
User=lifepex
Group=lifepex
WorkingDirectory=/srv/LifePex
Environment=PORT=8080
Environment=LIFEPEX_BASE_URL="https://lifepex.mydomain.org"
Environment=LIFEPEX_DB="sqlite://sqlite.db"
#Environment=LIFEPEX_SECRET=""
[Install]
WantedBy=default.target

10
scripts/nginx.conf Normal file
View File

@ -0,0 +1,10 @@
server {
listen 443 ssl;
server_name lifepex.sceptique.eu;
include reverse_proxy.conf;
location / {
proxy_pass http://127.0.0.1:10007;
}
}

13
scripts/update.sh Normal file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env sh
# Your env should already be setup before you run this
cd /srv/LifePex/
git checkout master
git fetch origin
git reset --hard origin/master --tags
bundle install
./init/database.rb
mkdir -p ./public/doc
./init/doc.rb
sudo systemctl restart lifepex

View File

@ -0,0 +1,55 @@
require_relative "./dsl"
module LifePex::AchievementDSL::Includer
extend LifePex::AchievementDSL
SUCCESS_LIST = []
SUCCESS_LIST << success("Marked a perk regularly") do
description "This success search for a serie of pexs in a span of time"
parameter "DaysAmountTotal" do
type :Integer
required
description "Duration in days to eval if the criteria are valids"
end
parameter "DaysAmountValidity" do
type :Integer
required
description "Amount of day over the total to validate the success"
end
parameter "MinimumAmountMarkedByDay" do
type :Integer
required
description "Minimum amount of mark for a day to be validated"
end
# parameter "PexIds" do
# type :Array
# description "List of pex that validates"
# end
parameter "Category" do
type :String
description "A category that validates"
end
to_complete do |user_pexs, pexs|
allowed_pex_ids = given(:PexIds)
allowed_pex_ids = pexs.filter { |pex| pex[:category] == given(:Category) }.map { |pex| pex[:id] } if given(:Category)
applicable = user_pexs.filter { |up| allowed_pex_ids.include?(up[:pex_id]) }
is_valid = false
counter = []
applicable.each do |up|
while !counter.empty? && (up[:created_at] - counter.first[:created_at]) > given(:DaysAmountTotal)
counter.shift
end unless counter.empty?
counter << up
grouped = counter.group_by { |c| c[:created_at] }
if grouped.values.filter { |tuple| tuple.size >= given(:MinimumAmountMarkedByDay) }.size >= given(:DaysAmountValidity)
is_valid = true
break
end
end
is_valid
end
end
SUCCESS_INDEX = SUCCESS_LIST.map { |s| [s.name, s] }.to_h
end

49
src/achievements/dsl.rb Normal file
View File

@ -0,0 +1,49 @@
module LifePex::AchievementDSL
UNDEFINED = :undefined_by_dsl
# @example
# include LifePex::AchievementDSL::Dslize
# dslize_input :name
# dslize_flag :required
module Dslize
def self.included(m)
def m.dslize_input(name)
define_method name do |v = UNDEFINED|
if v == :undefined_by_dsl
instance_variable_get "@#{name}"
else
instance_variable_set "@#{name}", v
end
end
end
def m.dslize_flag(name)
define_method "is_#{name}" do
instance_variable_get "@#{name}"
end
define_method name do
instance_variable_set "@#{name}", true
end
end
end
end
end
require_relative "./success.rb"
module LifePex::AchievementDSL
AVAILABLE_GENERIC_SUCCESS = {}
AVAILABLE_SETUP_SUCCESS = {}
def success(name, &block)
Success.new(name, &block)
# if block_given?
# s = Success.new(name, &block)
# LifePex::AchievementDSL::AVAILABLE_GENERIC_SUCCESS[name] = s
# s
# else
# LifePex::AchievementDSL::AVAILABLE_GENERIC_SUCCESS[name]
# end
end
end

View File

@ -0,0 +1,72 @@
class LifePex::AchievementDSL::Success
require_relative "./success/parameter.rb"
require_relative "./success/setup.rb"
include LifePex::AchievementDSL::Dslize
dslize_input :name
dslize_input :description
attr_reader :parameters
def initialize(name, &block)
@name = name
@parameters = []
@to_complete = nil
@description = nil
instance_eval(&block) if block_given?
end
def parameter(name, type = nil, &block)
@parameters << Parameter.new(name, type, &block)
end
def to_complete(&block)
@to_complete = block
end
def get_to_complete
@to_complete
end
def setup(name, to: 'default', &block)
Setup.new(self, name, &block)
# LifePex::AchievementDSL::AVAILABLE_SETUP_SUCCESS[to] ||= {}
# if block_given?
# s = Setup.new(self, name, &block)
# LifePex::AchievementDSL::AVAILABLE_SETUP_SUCCESS[to][name] = s
# s
# else
# LifePex::AchievementDSL::AVAILABLE_SETUP_SUCCESS[to][name]
# end
end
def static_cast_parameter_value(value: "", type: :String)
if type == :Integer
Integer(value)
elsif type == :Number || type == :Float
Float(value)
elsif type == :ArrayOfString
value.split(',')
elsif type == :ArrayOfInteger
raise "Invalid ArrayOfInteger" unless value =~ /^((\d+){1}?,?)*$/
value.split(',').map(&:to_i)
elsif type == :ArrayOfNumber
raise "Invalid ArrayOfNumber" unless value =~ /^((\d+(\.\d+)?){1}?,?)*$/
value.split(',').map(&:to_f)
else
value
end
end
def format_parameters_hash(parameters)
parameters.map do |key, value|
parameter = @parameters.find { |param| param.name == key }
raise RuntimeError.new "No #{key} parameter found" if parameter.nil?
begin
typed_value = static_cast_parameter_value(value: value, type: parameter.type)
rescue => err
raise "Invalid value \"#{value}\" for type #{parameter.type} of parameter \"#{parameter.name}\""
end
[key, typed_value]
end.to_h
end
end

View File

@ -0,0 +1,24 @@
class LifePex::AchievementDSL::Success::Parameter
include LifePex::AchievementDSL::Dslize
dslize_input :name
dslize_input :type
dslize_input :description
dslize_flag :required
def initialize(name, type = nil, &block)
@name = name
@type = type
@description = nil
instance_eval(&block) if block_given?
end
# not dsl related
def accept?(v)
return true if @input_type.nil?
return true if @input_type == :Numeric && v.is_a?(Numeric)
return true if @input_type == :Integer && v.is_a?(Numeric) && v.round(0) == v
return true if @input_type == :String && v.is_a?(String)
return true if @input_type == :Array && v.is_a?(Array)
return false
end
end

View File

@ -0,0 +1,48 @@
class LifePex::AchievementDSL::Success::Setup
include LifePex::AchievementDSL::Dslize
dslize_input :icon
dslize_input :name
attr_reader :success # FIXME: why cannot put success as dsl input ?
attr_reader :parameters
def initialize(success, name, &block)
@success = success
@name = name
@parameters = {}
@icon = nil
instance_eval(&block)
end
def parameter(**kv)
param_name = kv.keys.first.to_s
param_value = kv.values.first
param = @success.parameters.find { |param| param.name == param_name }
raise RuntimeError.new "Invalid value #{param_value} for #{param_name}" unless param_value.nil? && !param.is_required || param.accept?(param_value)
@parameters[param_name] = param_value
end
def call(user_pexs, pexs)
to_complete = @success.get_to_complete
instance_exec user_pexs, pexs, &to_complete
end
def given(name)
@parameters[name.to_s]
end
module Medal
def medal
if self.icon == "gold"
"&#129351;"
elsif self.icon == "silver"
"&#129352;"
elsif self.icon == "copper" || self.icon == "bronze"
"&#129353;"
else
"&#127941;"
end
end
end
include Medal
end

View File

@ -1,61 +1,100 @@
#!/usr/bin/env ruby
require "pry"
# require "sinatra"
require "sinatra/cookies"
require "sinatra/base"
require "sinatra/namespace"
require "slim"
require "bcrypt"
require "jwt"
require "sequel"
require "json"
require "doc_my_routes"
require_relative "./utils/url.rb"
module LifePex
DB = Sequel.connect ARGV[0] || ENV["LIFEPEX_DB"] || "sqlite://sqlite.db"
BASE_URL = ARGV[1] || ENV["LIFEPEX_BASE_URL"] || "http://localhost:4567"
SECRET = ENV['LIFEPEX_SECRET'] || "LifePexSecret"
puts "WARNING: Your secret is NOT very secret, thing about changing the LIFEPEX_SECRET" if SECRET == "LifePexSecret"
end
require "sinatra/base" # web
require "sinatra/cookies"
require "slim"
require "sinatra/namespace" # api
require "doc_my_routes" # api doc
require "bcrypt" # security
require "jwt"
require "rack/csrf"
require "securerandom"
require "sequel" # db
require "json" # helpers
require "active_support"
require "active_support/core_ext"
require "pry" # debug
# Load environment variables
require_relative "./utils/env"
module LifePex
APP_ENV = load_dotenv
end
# Initialize framework
require_relative "./utils/url.rb"
require_relative "./utils/boot_framework"
LifePex::BootFramework.boot_application do
harshly_need_env "LIFEPEX_DB"
kindly_ask_env "LIFEPEX_BASE_URL"
kindly_ask_env "LIFEPEX_SECRET" do
warning "\"LIFEPEX_SECRET\" will be randomly generated as a fallback"
ENV["LIFEPEX_SECRET"] = SecureRandom.hex(64)
end
harshly_do env["LIFEPEX_SECRET"].size < 8 do
error "Your secret is NOT very secret, fix this (at least 8 hexadigits entropy)"
end
end.finish!
# Setup module variables
module LifePex
DB = Sequel.connect ENV["LIFEPEX_DB"]
BASE_URL = ENV["LIFEPEX_BASE_URL"] || "http://localhost:4567"
SECRET = ENV["LIFEPEX_SECRET"]
CODE_VERSION = DB[:meta].first[:code_version]
CODE_DATE = DB[:meta].first[:code_date]
include LifePex::Utils::Url
module Systems
end
end
require_relative "./utils/json_api.rb"
# Then we load all the systems
require_relative "./models/level.rb"
require_relative "./models/user.rb"
require_relative "./models/pex.rb"
require_relative "./models/user_pex.rb"
Dir[File.join(__dir__, 'utils', '*.rb')].each { |file| require file }
require_relative "./systems/api_list.rb"
require_relative "./systems/auth.rb"
require_relative "./systems/user.rb"
require_relative "./systems/pex.rb"
require_relative "./systems/pex2.rb"
require_relative "./achievements/dsl.rb"
require_relative "./achievements/achievement.rb"
Dir[File.join(__dir__, 'models', '*.rb')].each { |file| require file }
Dir[File.join(__dir__, 'systems', '*.rb')].each { |file| require file }
# Static file serving in this file because it is overkill to create a file for this
class LifePex::Systems::PublicSystem < Sinatra::Base
set :public_folder, 'public'
set :public_folder, "public"
end
class LifePex::Systems::BaseSystem < Sinatra::Base
# Main app
class LifePex::App < Sinatra::Base
DocMyRoutes.configure do |config|
config.title = "LifePex"
config.description = "LifePex JSON REST API documentation"
end
set :session_secret, LifePex::SECRET
enable :sessions
use LifePex::Systems::AuthSystem
use LifePex::Systems::PublicSystem
use LifePex::Systems::UserSystem
use LifePex::Systems::PexSystem
use LifePex::Systems::Pex2System
# use Rack::Csrf, skip: ["POST:/api/*"], :raise => (LifePex::APP_ENV != "production") if LifePex::APP_ENV != "test"
set :protection, :except => :json_csrf if LifePex::APP_ENV != "test"
LifePex::Systems.constants
.filter { |system| system.to_s =~ /System$/ }
.each { |system|
use LifePex::Systems.const_get(system)
puts "Loaded #{system.to_s.green}"
}
# use LifePex::Systems::AuthSystem
# use LifePex::Systems::PublicSystem
# use LifePex::Systems::UserSystem
# use LifePex::Systems::PexSystem
# use LifePex::Systems::Pex2System
# use LifePex::Systems::AchievementSystem
# use LifePex::Systems::UserPexSystem
include JSON::API
not_found do
@ -66,9 +105,14 @@ class LifePex::Systems::BaseSystem < Sinatra::Base
end
end
get "/api/meta/v1" do
get "/api/meta/v1", provides: "json" do
LifePex::Systems::ApiList.get_all_api_routes.to_json
end
set :bind, ENV["LIFEPEX_BIND"] || "127.0.0.1"
set :environment, LifePex::APP_ENV
ENV["RACK_ENV"] = LifePex::APP_ENV
run! if app_file == $0
end

38
src/models/achievement.rb Normal file
View File

@ -0,0 +1,38 @@
class LifePex::Achievement < Sequel::Model(LifePex::DB[:achievements])
include LifePex::AchievementDSL::Success::Setup::Medal
many_to_one :user
# def validate
# super
# end
def parameters
JSON.parse(parameters_json)
end
def parameters=(parameters_hash)
self.parameters_json = parameters_hash.to_json
end
def set_dsl_setup(setup)
self.set(
name: setup.name,
success_name: setup.success.name,
parameters_json: setup.parameters.to_json,
icon: setup.icon,
)
end
# FIXME: use a singleton instead of an include
AVAILABLE_SUCCESS = LifePex::AchievementDSL::Includer::SUCCESS_INDEX.keys
def to_dsl_setup
current = self
current_parameters = self.parameters()
LifePex::AchievementDSL::Includer::SUCCESS_INDEX[current[:success_name]].setup(current[:name], to: current[:user_id]) do
icon current[:icon].to_s
current_parameters.each do |name, value|
parameter name => value
end
end
end
end

View File

@ -1,25 +1,21 @@
class LifePex::Pex < Sequel::Model(:pexs)
class LifePex::Pex < Sequel::Model(LifePex::DB[:pexs].order(:category, :name))
one_to_many :user_pexs
many_to_one :user
one_to_many :recalls
# note: wont work on #update
def before_validation
self.category = self.category.to_s.downcase
self.hidden = false if self.hidden.nil?
end
# common interface for setup_user_pexs_*
def self.setup_user_pexs(user_id: nil, user_pexs: nil)
if user_id
setup_user_pexs_by_user_id(user_id)
elsif user_pexs
setup_user_pexs_by_user_pexs(user_pexs)
else
raise "Invalid setup"
end
end
def self.setup_user_pexs_by_user_id(user_id)
user_pexs = LifePex::UserPex.where(user_id: user_id)
setup_user_pexs_by_user_pexs(user_pexs)
end
def self.setup_user_pexs_by_user_pexs(user_pexs)
pexs = LifePex::Pex.order(:name).all()
raise RuntimeError.new "user_id is required" if user_id.nil?
user_pexs = LifePex::UserPex.where(user_id: user_id) if user_pexs.nil?
pexs = LifePex::Pex
.where(user_id: user_id)
.order(:name).all()
.group_by { |pex| pex[:id] }
.map { |id, pex_group| [id, pex_group[0]] }
.to_h
@ -72,4 +68,8 @@ class LifePex::Pex < Sequel::Model(:pexs)
},
}
end
def bookmarked?
self.flag == "bookmarked"
end
end

4
src/models/recall.rb Normal file
View File

@ -0,0 +1,4 @@
class LifePex::Recall < Sequel::Model(LifePex::DB[:recalls].order(:name))
many_to_one :user
many_to_one :pex
end

View File

@ -1,4 +1,23 @@
class LifePex::User < Sequel::Model(:users)
one_to_many :user_pexs
one_to_many :pexs, order: %i(category name)
one_to_many :pexs
one_to_many :recalls
def password=(clear_password)
self.hashed_password = BCrypt::Password.create(clear_password)
end
def recalls_not_validated(cached: true)
@recalls_not_validated = nil if !cached
pexs = LifePex::Pex.setup_user_pexs(user_id: id, user_pexs: user_pexs)
@recalls_not_validated ||= recalls.filter do |recall|
pex = pexs[recall[:pex_id]]
validated = pex[:count_by_date].filter do |date, _|
date >= Date.today - recall[:span_duration]
end.values.sum
validated < recall[:repeated]
end
end
end

View File

@ -1,4 +1,12 @@
class LifePex::UserPex < Sequel::Model(:user_pexs)
many_to_one :user
many_to_one :pex
def self.last_inserted_at(user_id)
LifePex::UserPex
# .join(:pexs, :id => :pex_id)
# .where(Sequel[:user_pexs][:user_id] => user_id)
# .group(:pex_id)
# .select_append{max(created_at).as(:last_inserted_at)}
end
end

View File

@ -0,0 +1,47 @@
require_relative "./csrf.rb"
require_relative "./auth.rb"
class LifePex::Systems::AchievementSystem < LifePex::Systems::AuthSystem
include LifePex::Systems::CrlfHelper
get "/achievements", auth: [] do
achievements = LifePex::Achievement.where(user_id: current_user_id).all
slim :achievements, locals: { achievements: achievements }
end
get "/achievements/new", auth: [] do
success = LifePex::AchievementDSL::Includer::SUCCESS_INDEX[params["successName"]]
slim :achievement_form, locals: { success: success }
end
post "/achievements", auth: [] do
success = LifePex::AchievementDSL::Includer::SUCCESS_INDEX[params["successName"]]
begin
parameters_json = success.format_parameters_hash(params["inputParams"]).to_json
achievement = LifePex::Achievement.new(
user_id: current_user_id,
success_name: params["successName"],
name: params["inputName"],
icon: params["inputMedal"],
parameters_json: parameters_json,
).save
achievements = LifePex::Achievement.where(user_id: current_user_id).all
slim :achievements, locals: { achievements: achievements, flash: { success: "Added achievements '#{achievement.name}" } }
rescue => err
success = LifePex::AchievementDSL::Includer::SUCCESS_INDEX[params["successName"]]
slim :achievement_form, locals: { success: success, flash: { danger: err.message } }
end
end
post "/achievements/delete", auth: [] do
amount = LifePex::Achievement.where(user_id: current_user_id, id: params['id']).destroy
achievements = LifePex::Achievement.where(user_id: current_user_id).all
slim :achievements, locals: { achievements: achievements, flash: { success: "Removed #{amount} achievements" } }
end
# include JSON::API
# extend DocMyRoutes::Annotatable
# register Sinatra::Namespace
# include LifePex::Systems::ApiList
end

View File

@ -0,0 +1,22 @@
module LifePex::Systems::ApiResponse
def api_response(any)
content_type 'application/json'
any.to_json
end
def api_response_entity(message = nil, entity_type = nil, entity = nil, **more)
api_response({
"message" => message,
"entity_type" => entity_type,
entity_type => entity,
**more,
}.compact)
end
def api_error(status = 500, message = "Internal error", **more)
halt(status, {
message => message,
**more,
}.to_json)
end
end

View File

@ -1,11 +1,33 @@
class LifePex::Systems::AuthSystem < Sinatra::Base
helpers Sinatra::Cookies
include JSON::API
include LifePex::UsersHelper
def setup_user_cookie!(user_id)
response.set_cookie(
"auth",
{
value: JWT.encode({ "user_id" => user_id }, LifePex::SECRET),
expires: Time.now + 2.days,
path: "/",
})
end
def renew_user_cookie!
response.set_cookie(
"auth",
{
value: cookies["auth"],
expires: Time.now + 2.days,
path: "/",
})
end
def user_id_decoded(cookies = nil)
cookies = cookies() if cookies.nil?
begin
decoded = JWT.decode(cookies["auth"], LifePex::SECRET)
renew_user_cookie!
decoded[0]["user_id"]
rescue => err
STDERR.puts "user_id_decoded: #{err}"
@ -37,7 +59,7 @@ class LifePex::Systems::AuthSystem < Sinatra::Base
condition do
unless logged_in?
if accept_json?
halt 303, { message: 'You need to /api/v1/register an account and /api/v1/login to get a cookie first' }.to_json
halt 401, { message: 'You need to POST /api/user/v1/register to register an account and POST /api/user/v1/login to get a cookie first' }.to_json
else
redirect "/login", 303
end

13
src/systems/csrf.rb Normal file
View File

@ -0,0 +1,13 @@
module LifePex::Systems::CrlfHelper
def self.included(m)
m.helpers do
def csrf_token
Rack::Csrf.csrf_token(env)
end
def csrf_tag
Rack::Csrf.csrf_tag(env)
end
end
end
end

View File

@ -1,49 +1,38 @@
require "date"
require "active_support/all"
require_relative "./auth.rb"
require_relative "./csrf.rb"
require_relative "../utils/users.rb"
class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
include JSON::API
def my_user_pexs cookies, date = nil
params = {
user_id: user_id_decoded(cookies),
}
params[:created_at] = date unless date.nil?
LifePex::UserPex.where(params).all()
end
def get_user_date
date = cookies["date"]
if date && Date.respond_to?(date)
Date.send date
elsif date == "yesterday"
Date.today - 1
else
Date.today
end
end
include LifePex::Systems::CrlfHelper
get "/today", auth: [] do
cookies["date"] = "today"
cookies.set "date", { value: "now", httponly: false }
redirect "/"
end
get "/yesterday", auth: [] do
cookies["date"] = "yesterday"
cookies.set "date", { value: "yesterday", httponly: false }
redirect "/"
end
def pex_by_models(params)
def pex_by_models(params, filter_hidden: true)
user_pexs = my_user_pexs(cookies, get_user_date)
user_pexs_amounts = user_pexs.group_by { |user_pex| user_pex[:pex_id] }
pexs = current_user.pexs.to_a
pexs = LifePex::Pex.where(user_id: current_user_id)
pexs = pexs.where(hidden: false) if filter_hidden
pexs = pexs.all
user_pexs_last_inserted_at = LifePex::UserPex.last_inserted_at(current_user_id).all
pex_by_models = pexs.map do |pex|
{
id: pex[:id],
name: pex[:name],
amount: pex[:amount],
category: pex[:category],
pex: pex,
user_pexs: {
amount: (user_pexs_amounts[pex[:id]] || []).length
at_date: (user_pexs_amounts[pex[:id]] || []).length,
last_inserted_at: (user_pexs_last_inserted_at.find{ |up| up[:pex_id] == pex[:id] } || {})[:last_inserted_at],
},
}
end
@ -51,7 +40,7 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
end
get "/", auth: [], provides: 'html' do
pex_by_models = pex_by_models(params)
pex_by_models = pex_by_models(params, filter_hidden: params['filter_hidden'] != 'false')
slim :index, locals: { pex_by_models: pex_by_models }
end
@ -59,28 +48,39 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
user_id = user_id_decoded(cookies)
pex_id = params["id"]
if params["type"] == "+"
LifePex::UserPex.insert(user_id: user_id, pex_id: pex_id, created_at: get_user_date)
LifePex::UserPex.new(user_id: user_id, pex_id: pex_id, created_at: get_user_date).save
elsif params["type"] == "-"
id = LifePex::UserPex.where(user_id: user_id, pex_id: pex_id, created_at: get_user_date).select(:id).first[:id]
LifePex::UserPex.where(id: id).delete
user_pex = LifePex::UserPex.where(user_id: user_id, pex_id: pex_id, created_at: get_user_date).select(:id).first
halt 400, "No user pex can be found" if user_pex.nil?
LifePex::UserPex.where(id: user_pex[:id]).delete
end
redirect "/"
end
get "/pexs", auth: [] do
slim :pex_form
categories = LifePex::Pex
.select(:category)
.where(user_id: current_user_id)
.distinct
.pluck(:category)
slim :pex_form, locals: { categories: categories }
end
get "/pexs/update", auth: [] do
pex = LifePex::Pex.where(id: params["id"]).first
slim :pex_form, locals: { pex: pex, submit_name: "update" }
categories = LifePex::Pex
.select(:category)
.where(user_id: current_user_id)
.distinct
.pluck(:category)
slim :pex_form, locals: { pex: pex, categories: categories, submit_name: "update" }
end
post "/pexs", auth: [] do
pex_id = if params["id"]
LifePex::Pex.where(id: params["id"]).update(name: params['name'], category: params['category'], amount: params['amount'])
LifePex::Pex.where(id: params["id"]).update(name: params['name'], category: params['category'].to_s.downcase, amount: params['amount'])
else
LifePex::Pex.insert(name: params['name'], category: params['category'], amount: params['amount'], user_id: current_user.id)
LifePex::Pex.new(name: params['name'], category: params['category'], amount: params['amount'], user_id: current_user.id).save
end
if pex_id
redirect "/"
@ -95,6 +95,19 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
slim :pex_removed, locals: { flash: { success: "#{removed} model and #{links_removed} marks removed" } }
end
post "/pexs/hide", auth: [] do
pex = LifePex::Pex.find(id: params['id'])
pex.set(hidden: !pex.hidden).save
redirect '/'
end
post "/pexs/bookmark", auth: [] do
pex = LifePex::Pex.find(id: params['id'])
set_flag = pex.bookmarked? ? "" : "bookmarked"
pex.set(flag: set_flag).save
redirect '/'
end
def compute_highchart_cumul(pex_data)
total = 0
pex_data.map do |v|
@ -102,19 +115,28 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
end
end
def get_recap_infos(cookies)
def get_recap_infos(params, cookies)
user_pexs = my_user_pexs(cookies)
pexs = LifePex::Pex.setup_user_pexs(user_pexs: user_pexs)
pexs = LifePex::Pex.setup_user_pexs(user_id: current_user_id, user_pexs: user_pexs)
total_xp = pexs.values.sum { |up| up[:total_by_date].values.sum }
level = LifePex::Level.new total_xp
all_dates = user_pexs.map(&:created_at).uniq
all_category = pexs.values.map(&:category).uniq
start_date = 60.days.ago.to_date
if params["days_ago"]
start_date = params["days_ago"].to_i.days.ago.to_date
elsif params["since_first_jan"]
year = Date.today.year
first_jan = Date.new(year, 1, 1)
start_date = first_jan
end
all_dates = (start_date..(Date.today)).to_a
pex_table_by_date = all_dates.map { |date| [date, 0] }.to_h
pexs.values.each do |pex|
pex[:total_by_date].each do |date, total|
# pex_table_by_date[date] ||= 0
pex_table_by_date[date] += total
pex_table_by_date[date] += total if pex_table_by_date[date]
end
end
@ -130,7 +152,7 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
category = pex[:category]
# pex_table_by_category_and_date[category] ||= {}
# pex_table_by_category_and_date[category][date] ||= 0
pex_table_by_category_and_date[category][date] += total
pex_table_by_category_and_date[category][date] += total if pex_table_by_category_and_date[category][date]
end
end
@ -145,14 +167,32 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
}
end.unshift(highchart_total)
recalls = LifePex::Recall.where(user_id: current_user_id).all
recalls_not_validated = recalls.filter do |recall|
pex = pexs[recall[:pex_id]]
validated = pex[:count_by_date].filter do |date, _|
date >= Date.today - recall[:span_duration]
end.values.sum
validated < recall[:repeated]
end
all_setup = LifePex::Achievement.where(user_id: current_user_id).map(&:to_dsl_setup)
medals = all_setup.filter do |success|
success.call(user_pexs, pexs.values)
end
{
pex_tables: highchart_tables,
start_date: start_date,
all_dates: all_dates,
level: level,
medals: medals,
recalls_not_validated: recalls_not_validated,
}
end
get "/recap", auth: [], provides: 'html' do
recap_infos = get_recap_infos(cookies)
recap_infos = get_recap_infos(params, cookies)
slim :recap, locals: recap_infos
end
@ -163,9 +203,10 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
produces 'application/json'
status_codes [200]
get "/recap", auth: [], provides: 'json' do
recap_infos = get_recap_infos(cookies)
recap_infos = get_recap_infos(params, cookies)
{
pex_tables: recap_infos[:pex_tables],
all_dates: recap_infos[:all_dates],
level: recap_infos[:level].to_h,
}.to_json
end
@ -194,12 +235,12 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
parameter :amount, required: true, type: 'number', in: 'body'
parameter :auth, required: true, type: 'string', in: 'cookies'
post "/", auth: [], provides: 'json' do
pex_id = LifePex::Pex.insert(
pex_id = LifePex::Pex.new(
name: json_params['name'],
category: json_params['category'],
amount: json_params['amount'],
user_id: current_user.id,
)
).save
{
message: "entity created",
pex: LifePex::Pex.find(id: pex_id),
@ -231,7 +272,7 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
post "/by_id/:id", auth: [], provides: 'json' do
LifePex::Pex.where(id: params["id"]).update(
name: json_params['name'],
category: json_params['category'],
category: json_params['category'].to_s.downcase,
amount: json_params['amount'],
)
{
@ -266,7 +307,7 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
post "/by_id/:id/mark", auth: [], provides: 'json' do
user_id = user_id_decoded
pex_id = params["id"]
LifePex::UserPex.insert(user_id: user_id, pex_id: pex_id, created_at: get_user_date)
LifePex::UserPex.new(user_id: user_id, pex_id: pex_id, created_at: get_user_date).save
{ message: "ok" }.to_json
end

View File

@ -2,6 +2,7 @@ require_relative "./pex.rb"
class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
# include JSON::API # included by PexSystem
include LifePex::Systems::ApiResponse
# extend DocMyRoutes::Annotatable # included by PexSystem
# register Sinatra::Namespace # included by PexSystem
@ -24,7 +25,7 @@ class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
produces 'application/json'
status_codes [200]
parameter :pluck, required: false, type: 'string', in: 'query', description: 'improve performance by only fetching one field'
get "", auth: [], provides: 'json' do
get '', auth: [], provides: "json" do
pexs = current_user.pexs
pexs = pexs.select(json_params["pluck"]) if json_params["pluck"]
pexs.to_json
@ -37,10 +38,15 @@ class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
parameter :category, required: true, type: 'string', in: 'body'
parameter :amount, required: true, type: 'number', in: 'body'
parameter :auth, required: true, type: 'string', in: 'cookies'
post "/", auth: [], provides: 'json' do
raise RunetimeError.new "\"name\" is required" unless json_params["name"]
raise RunetimeError.new "\"category\" is required" unless json_params["category"]
raise RunetimeError.new "\"amount\" is required" unless json_params["amount"]
post '', auth: [], provides: 'json' do
halt 400, "\"name\" is required" unless json_params["name"]
halt 400, "\"category\" is required" unless json_params["category"]
halt 400, "\"amount\" is required" unless json_params["amount"]
halt 409, "conflict with existing pex with the same characteristics" if LifePex::Pex.find(
name: json_params['name'],
category: json_params['category'],
user_id: current_user.id,
)
pex_id = LifePex::Pex.insert(
name: json_params['name'],
category: json_params['category'],
@ -53,54 +59,148 @@ class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
}.to_json
end
summary 'Update an existing pex'
produces 'application/json'
status_codes [200]
parameter :id, require: true, type: 'integer', in: 'path'
parameter :name, required: false, type: 'string', in: 'body'
parameter :category, required: false, type: 'string', in: 'body'
parameter :amount, required: false, type: 'number', in: 'body'
parameter :auth, required: true, ype: 'string', in: 'cookies'
put '/:id', auth: [], provides: 'json' do
pex = LifePex::Pex.find(
id: params["id"],
user_id: current_user.id,
)
halt 404, { message: "No pex #{params["id"]}" }.to_json if pex.nil?
pex.name = json_params['name'] unless json_params['name'].nil?
pex.category = json_params['category'] unless json_params['category'].nil?
pex.amount = json_params['amount'] unless json_params['amount'].nil?
pex.save
{
message: "entity updated",
pex: pex,
}.to_json
end
summary 'Delete an existing pex'
produces 'application/json'
status_codes [200]
parameter :id, require: true, type: 'integer', in: 'path'
parameter :auth, required: true, type: 'string', in: 'cookies'
delete '/:id', auth: [], provides: 'json' do
pex = LifePex::Pex.find(
id: params["id"],
user_id: current_user.id,
)
halt 404, { message: "No pex #{params["id"]}" }.to_json if pex.nil?
pex.destroy
{
message: "entity deleted",
pex: pex,
}.to_json
end
summary 'Show the state of a pex'
produces 'application/json'
status_codes [200]
parameter :id, required: true, type: 'string', in: 'path'
get "/:id", auth: [], provides: 'json' do
{
pex: LifePex::Pex.find(
get '/:id', auth: [], provides: 'json' do
pex = LifePex::Pex.find(
id: params["id"],
user_id: current_user.id,
),
)
halt 404, { message: "No pex #{params["id"]}" }.to_json if pex.nil?
{
pex: pex,
}.to_json
end
summary 'Show the state of a pex and load advanced infos about it'
produces 'application/json'
produces 'application/json,text/html'
status_codes [200]
parameter :id, required: true, type: 'string', in: 'path'
get "/:id/more", auth: [], provides: 'json' do
get '/:id/more', auth: [], provides: 'json' do
pex = LifePex::Pex.find(
id: params["id"],
user_id: current_user_id,
)
{
halt 404, { message: "No pex #{params["id"]}" }.to_json if pex.nil?
user_pexs = LifePex::UserPex.where(
user_id: current_user_id,
pex_id: pex.id
).select(:created_at).all
date = date_input_convertor(json_params["date"])
user_pexs_by_date = user_pexs.group_by(&:created_at).transform_values { |group| group.size }
user_pexs_at_date = user_pexs_by_date[date]
pex_with_more = {
pex: pex,
user_pexs: LifePex::UserPex.where(user_id: current_user_id, pex_id: pex.id).select(:created_at).all.group_by(&:created_at).transform_values { |group| group.size }
}.to_json
user_pexs: {
by_date: user_pexs_by_date,
at_date: user_pexs_at_date,
total: user_pexs.size,
},
}
given_content_type do
json pex_with_more.to_json
html slim_partial("pex_row", locals: { pex_with_amount: pex_with_more })
default do
api_error(400, message: "No valid accepted content found.")
end
end
end
summary 'Get the amount of a pex each day since first occurence since 1 january'
notes ""
summary 'Get the amount of a pex each day since first occurence since a given date'
notes ''
produces 'application/json'
status_codes [200]
parameter :id, required: true, type: 'integer', in: 'path'
parameter :auth, required: true, type: 'string', in: 'cookies'
post "/:id/recap", auth: [], provides: 'json' do
user_id = user_id_decoded
parameter :days_ago, required: false, type: 'integer', in: 'body', description: "default: 30, limit the amount of results to the n last days included"
parameter :since_first_jan, required: false, type: 'boolean', in: 'body', description: "default: false, if no days_ago defined, limit to all dates after (included) 1st jan of the current year"
post '/:id/recap', auth: [], provides: 'json' do
pex_id = params["id"]
pex = LifePex::Pex.find(id: pex_id, user_id: current_user_id)
halt 404, { message: "No pex #{params["id"]}" }.to_json if pex.nil?
start_date = 30.days.ago
if params["days_ago"]
start_date = params["days_ago"].to_i.days.ago
elsif params["since_first_jan"]
year = Date.today.year
first_jan = Date.new(year, 1, 1)
pex = LifePex::Pex.find(id: pex_id)
user_pexs = LifePex::UserPex.where {
self.user_id == user_id && self.pex_id == pex_id && created_at > first_jan
}.first
start_date = first_jan
end
user_pexs = LifePex::UserPex.where { # TODO query that
self.user_id == current_user_id && self.pex_id == pex_id && created_at >= start_date
}
{ pex: pex, user_pexs: user_pexs }.to_json
end
end
namespace '/infos' do
summary 'Get for all existing pex the last date a user_pex has been added'
produces 'application/json'
status_codes [200]
parameter :id, required: true, type: 'string', in: 'path'
get '/last-inserts', auth: [], provides: 'json' do
pexs = LifePex::UserPex.last_inserted_at(current_user.id)
pexs.map { |pex|
{
pex_id: pex[:pex_id],
name: pex[:name],
last_inserted_at: pex[:last_inserted_at],
}
}.to_json
end
end
end
include LifePex::Systems::ApiList
end

47
src/systems/recall.rb Normal file
View File

@ -0,0 +1,47 @@
require_relative "./csrf.rb"
require_relative "./auth.rb"
class LifePex::Systems::RecallSystem < LifePex::Systems::AuthSystem
include LifePex::Systems::CrlfHelper
get "/recalls", auth: [] do
recalls = current_user.recalls
slim :recalls, locals: { recalls: recalls }
end
get "/recalls/new", auth: [] do
pexs = current_user.pexs
slim :recall_form, locals: { pexs: pexs }
end
post "/recalls", auth: [] do
flash = {}
if !LifePex::Pex.where(user_id: current_user_id, id: params["pex_id"]).first
flash[:danger] = "Invalid pex"
else
recall = LifePex::Recall.new(
user_id: current_user_id,
pex_id: params["pex_id"],
name: params["name"],
span_duration: params["span_duration"],
repeated: params["repeated"],
).save
flash[:success] = "Recall #{recall.name} created"
end
recalls = current_user.recalls
slim :recalls, locals: { recalls: recalls, flash: flash }
end
post "/recalls/delete", auth: [] do
recall = LifePex::Recall.where(user_id: current_user_id, id: params["id"]).first
flash = {}
if recall
recall.destroy
flash[:success] = "Recall #{recall.name} removed"
else
flash[:danger] = "Recall do not exists"
end
recalls = current_user.recalls
slim :recalls, locals: { recalls: recalls, flash: flash }
end
end

View File

@ -1,7 +1,9 @@
require "yaml"
require_relative "./auth.rb"
require_relative "./csrf.rb"
class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
include JSON::API
include LifePex::Systems::CrlfHelper
DEFAULT_PEXS_FOR_NEW_USERS = YAML.load_file "config/default_pexs_for_new_users.yaml"
@ -12,7 +14,7 @@ class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
def login(params)
user = LifePex::User.where(username: params["username"]).first
if user && BCrypt::Password.new(user[:hashed_password]) == params["password"]
cookies["auth"] = JWT.encode({ "user_id" => user[:id] }, LifePex::SECRET)
setup_user_cookie!(user[:id])
user
else
nil
@ -21,7 +23,6 @@ class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
post "/login", provides: 'html' do
if user = login(params)
cookies["auth"] = JWT.encode({ "user_id" => user[:id] }, LifePex::SECRET)
redirect "/"
else
slim :login, locals: { flash: { danger: "Failed to login" } }
@ -36,12 +37,12 @@ class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
user = LifePex::User.where(username: params["username"]).first
if !user
hashed_password = BCrypt::Password.create params["password"]
user_id = LifePex::User.insert(username: params["username"], hashed_password: hashed_password)
cookies["auth"] = JWT.encode({ "user_id" => user_id }, LifePex::SECRET)
user = LifePex::User.new(username: params["username"], hashed_password: hashed_password).save
cookies["auth"] = JWT.encode({ "user_id" => user.id }, LifePex::SECRET)
DEFAULT_PEXS_FOR_NEW_USERS.each do |pex|
LifePex::Pex.insert(**pex, user_id: user_id)
LifePex::Pex.new(**pex, user_id: user.id).save
end
user_id
user.id
else
nil
end
@ -55,7 +56,7 @@ class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
end
end
get "/logout", auth: [] do
post "/logout", auth: [] do
cookies.delete "auth"
slim :logout, locals: { flash: { success: "Logged out" } }
end
@ -66,7 +67,7 @@ class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
def change_password(params)
hashed_password = BCrypt::Password.create params["password"]
current_user.update(hashed_password: hashed_password)
LifePex::User.where(id: current_user_id).update(hashed_password: hashed_password)
end
post "/password", auth: [], provides: 'html' do
@ -82,6 +83,40 @@ class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
slim :about
end
get "/preferences" do
show_preferences
end
UserPreferenceCookie = Struct.new(:cookie, :allow_blank, :convert, :html, :description)
USER_PREFERENCES = {
"inputRecapDays" => UserPreferenceCookie.new("recap_days", false, :to_i, { type: "text" }, "Amount of days to show in the recap"),
"inputLateDayOffset" => UserPreferenceCookie.new("late_day_offset", false, :to_f, { type: "number", min: "0", max: "24", step: "0.1" }, "Offset for today (so a few hours after midnight is still today)"),
"inputShowFullDate" => UserPreferenceCookie.new("show_full_date", true, :to_s, { type: "checkbox" }, "Show or hide the date of the current tab"),
}
post "/preferences" do
USER_PREFERENCES.each do |param_name, upc|
current_param = params[param_name]
next if !upc.allow_blank && current_param.blank?
cookies[upc.cookie] =
case upc.convert
when Symbol
current_param.send(upc.convert)
when Proc
upc.convert.call(current_param)
else
current_param
end
end
show_preferences
end
def show_preferences
slim :preferences
end
extend DocMyRoutes::Annotatable
register Sinatra::Namespace
namespace '/api/user/v1' do

138
src/systems/user_pex.rb Normal file
View File

@ -0,0 +1,138 @@
require_relative "./auth.rb"
require_relative "./api_response"
require "csv"
class LifePex::Systems::UserPexSystem < LifePex::Systems::AuthSystem
include LifePex::Systems::ApiResponse
set :protection, :except => [:frame_options, :json_csrf]
extend DocMyRoutes::Annotatable # included by PexSystem
register Sinatra::Namespace # included by PexSystem
namespace "/api/user-pex/v1" do
summary "Export every single user pex and pex"
produces "application/json,application/csv"
status_codes [200]
# parameter :id, required: true, type: "integer", in: "path"
# parameter :date, required: true, type: "string", in: "path"
get "/export", auth: [], provides: %w(json application/csv) do
if accept? "application/csv"
export_csv
else
pexs = LifePex::Pex.where(user_id: current_user_id).map{ |pex| [pex, pex.user_pexs] }.to_h
api_response({ pexs: pexs, entity_type: "pexs" })
end
end
summary "Export every single user pex and pex as CSV"
produces "application/csv"
status_codes [200]
# parameter :id, required: true, type: "integer", in: "path"
# parameter :date, required: true, type: "string", in: "path"
get "/export.csv", auth: [], provides: %w(application/csv) do
export_csv
end
private def export_csv
pexs = LifePex::Pex.where(user_id: current_user_id).order(:id).all
pexs_names = pexs.map { |pex| "[#{pex.category}] #{pex.name}" }
pexs_ids = pexs.map(&:id)
user_pexs_by_date = LifePex::UserPex
.where(Sequel.qualify(:user_pexs, :user_id) => current_user_id)
.order_by(:created_at).all.group_by(&:created_at)
csv_output = CSV.generate do |csv|
csv << ["date", *pexs_names]
user_pexs_by_date.each do |date, user_pexs|
amount_by_column = user_pexs.group_by(&:pex_id).transform_values { |v| v.size }
user_pex_amount_by_date_all_column = pexs_ids.map { |id| amount_by_column[id] || 0 }
csv << [date, *user_pex_amount_by_date_all_column]
end
end
content_type "application/csv"
csv_output
end
namespace "/pexs" do
summary "Get the amount of user_pex for a given day"
produces "application/json"
status_codes [200]
parameter :id, required: true, type: "integer", in: "path"
parameter :date, required: true, type: "string", in: "path"
get "/:pex_id/amount/by-date/:date", auth: [], provides: "json" do
pex_id = params["pex_id"]
date = params["date"]
count = LifePex::UserPex.where(
user_id: current_user_id,
pex_id: pex_id,
created_at: date,
).count
api_response({ count: count, entity_type: "user_pex" })
end
summary "Create a new user_pex for a given day"
produces "application/json"
status_codes [200]
parameter :id, required: true, type: "integer", in: "path"
parameter :date, required: false, type: "string", in: "body"
parameter :force_count_total, required: false, type: "bool", in: "body", description: "if true, force the server to computes the total amount of validation compatible after the operation"
post "/:pex_id/validation", auth: [], provides: "json" do
pex_id = params["pex_id"]
date = date_input_convertor(json_params["date"])
api_error(403, "You may not have created or access to this pex id") if LifePex::Pex.where(id: pex_id).select(:user_id).first&.user_id != current_user_id
user_pex = LifePex::UserPex.new(
user_id: current_user_id,
pex_id: pex_id,
created_at: date,
).save
count_total = LifePex::UserPex.where(
user_id: current_user_id,
pex_id: pex_id,
created_at: date,
).count if json_params["force_count_total"] == true
api_response_entity(
"Successfuly added one user_pex",
"user_pex",
user_pex,
count_total: count_total,
)
end
summary "Remove an existing user_pex for a given day"
produces "application/json"
status_codes [200]
parameter :id, required: true, type: "integer", in: "path"
parameter :date, required: false, type: "string", in: "body"
parameter :force_count_total, required: false, type: "bool", in: "body", description: "if true, force the server to computes the total amount of validation compatible after the operation"
delete "/:pex_id/validation", auth: [], provides: "json" do
pex_id = params["pex_id"]
date = date_input_convertor(json_params["date"])
user_pex = LifePex::UserPex.where(
user_id: current_user_id,
pex_id: pex_id,
created_at: date,
).select(:id).first
if user_pex
user_pex.destroy
count_total = LifePex::UserPex.where(
user_id: current_user_id,
pex_id: pex_id,
created_at: date,
).count if json_params["force_count_total"] == true
api_response({
message: "Successfuly destroyed 1 user_pex",
entity_type: "user_pex",
count_destroyed: 1,
count_total: count_total,
})
else
api_error(404, "Nothing to destroy")
end
end
end
end
include LifePex::Systems::ApiList
end

1
src/systems/views Symbolic link
View File

@ -0,0 +1 @@
../views/

View File

@ -0,0 +1,79 @@
require "colorize"
# @example
# LifePex::BootFrameWork::Application.new(ENV).boot do
# harshly_need_env "VARIABLE_NAME1"
# kindly_ask_env "VARIABLE_NAME2" { do_something_if_failure() }
# harshly_do true
# kindly_do env["SOME_OTHER_VARIABLE"] { error "this should not happen :(((" }
# end.finish!
module LifePex::BootFramework
class Application
attr_reader :env, :stop
def initialize(env)
@env = env
@stop = false
end
private def message(string)
STDERR.puts string
end
def error(string)
message "Error: #{string}".red
end
def warning(string)
message "Warning: #{string}".yellow
end
def boot(&block)
instance_eval(&block)
self
end
def finish!
if @stop
exit 1
end
self
end
def will_stop!
@stop = true
end
def harshly_need_env(env_variable, &block)
@env.fetch env_variable do
error "\"#{env_variable}\" is a required ENV variable. Provide it.".red
will_stop!
instance_eval(&block) if block_given?
end
end
def kindly_ask_env(env_variable, &block)
@env.fetch env_variable do
warning "\"#{env_variable}\" is a prefered ENV variable. Provide it if possible.".yellow
instance_eval(&block) if block_given?
end
end
def harshly_do(test, &block)
if test
instance_eval(&block) if block_given?
will_stop!
end
end
def kindly_do(test, &block)
if test
instance_eval(&block) if block_given?
end
end
end
def self.boot_application(&block)
Application.new(ENV).boot(&block)
end
end

11
src/utils/env.rb Normal file
View File

@ -0,0 +1,11 @@
require "dotenv"
def get_env
ENV.fetch("LIFEPEX_ENV") { "development" }
end
def load_dotenv(app_env = nil)
app_env ||= get_env
Dotenv.load(".env.#{app_env}.local", ".env.local", ".env")
app_env
end

View File

@ -1,3 +1,5 @@
require "active_support/all"
module JSON::API
def json_params
begin
@ -10,7 +12,103 @@ module JSON::API
end
end
DATE_GENERATOR = {
"yesterday" => ->() { DateTime.now - 24.hours },
"now" => ->() { DateTime.now },
"today" => ->() { DateTime.now },
nil => ->() { DateTime.now },
}
# @param [String] date: either a date iso formatted or a word to be sent to DateTime
# @param [Float] offset: an amount of hours to remove from the date, useful for setting the start of the day hours after midnight
def date_input_convertor(date = "now", offset = 0.0)
base_time =
if DATE_GENERATOR.key?(date)
DATE_GENERATOR[date].call()
else
begin
DateTime.parse date
rescue => _
DateTime.now
end
end
(base_time - offset.hours).to_date
end
# params:
# - mimes: example: %w(application/json text/json)
def accept?(*mimes)
@request_accept_str ||= request.accept.map(&:to_str)
@request_accept_str.intersection(mimes).size > 0
end
def accept_json?
request.accept.any? { |a| a.entry == 'application/json' }
accept? 'application/json'
end
def accept_html?
accept? 'text/html'
end
def slim_partial(partial, locals: {})
Slim::Template.new(
"src/views/partials/#{partial}.slim",
# layout: false,
).render(self, locals)
end
# Example
# render do
# json "{ ... }"
# html "< ... >"
# end
def given_content_type(&block)
raise "Invalid DSL call to JSON::API.render without block" unless block_given?
Render.new(self, &block).complete_rendering
end
class Render
delegate :slim,
:slim_partial,
# :api_error,
# :api_response,
# :render,
to: :@context
attr_reader :context
def initialize(context, &block)
@render = nil
@context = context
instance_eval(&block) if block_given?
end
def render_something_if_accept(acceptance, output, &block)
if block_given?
context.instance_eval(&block)
else
@render = output
end if @context.send("accept_#{acceptance}?")
end
def json(output = nil, &block)
render_something_if_accept :json, output, &block
end
def html(output = nil, &block)
render_something_if_accept :html, output, &block
end
def default(output = nil, &block)
if block_given?
@render_default_block = block
else
@render_default = output
end
end
def complete_rendering
@render ||
(@render_default_block.is_a?(Proc) && @context.instance_eval(&@render_default_block)) ||
@render_default
end
end
end

5
src/utils/string.rb Normal file
View File

@ -0,0 +1,5 @@
class String
def camel_to_spaced
self.gsub(/([a-z])([A-Z])/, '\1 \2').capitalize
end
end

15
src/utils/users.rb Normal file
View File

@ -0,0 +1,15 @@
module LifePex::UsersHelper
def my_user_pexs(cookies, date = nil)
params = {
user_id: user_id_decoded(cookies),
}
params[:created_at] = date unless date.nil?
LifePex::UserPex.where(params).all()
end
def get_user_date
date = cookies["date"]
offset = cookies["late_day_offset"].to_f
date_input_convertor(date, offset)
end
end

View File

@ -17,7 +17,10 @@
I engage my honor to do never read or modify personnal data you may have put on
the server, and do my best to ensure its security.
You should look at the code source if you want to audit it.
br
h2
| GDPR
p
| In case you want to take a look at your data or want to delete it, I may in the futur
provide a feature for do it yourself.
Meanwhile you can still drop me an issue or a message on &nbsp;
@ -26,6 +29,8 @@
| &nbsp;
i
| #lifepex.
a.btn.btn-success(href="/api/user-pex/v1/export.csv")
| Export as CSV
h2
| Service version

View File

@ -0,0 +1,25 @@
main.col-md-9.ms-sm-auto.col-lg-10.px-md-4
h1
| Add a new Achievement
form.col-md-6 method="POST" action="/achievements"
== csrf_tag
input type="hidden" name="successName" value=success.name
.form-group.row
label.col-sm-12.col-form-label for="inputName"
strong
| Name
input#inputUsername.form-control.form-control-lg name="inputName" type="text" /
- success.parameters.each do |param|
.form-group.row
label.col-sm-12.col-form-label for="inputParams[#{param.name}]"
strong
= param.name.camel_to_spaced
- if param.required
| *
p=param.description
input.form-control.form-control-lg name="inputParams[#{param.name}]" type="text" /
.form-group.row
label.col-sm-12.col-form-label for="inputMedal" Medal
input#inputMedal.form-control.form-control-lg name="inputMedal" type="text" placeholder="gold" /
.form-group.row
input.btn.btn-lg.btn-block type="submit" value="create"

View File

@ -0,0 +1,35 @@
h1
| Current achievements
- LifePex::Achievement::AVAILABLE_SUCCESS.each do |available_success|
a.btn.btn-dark href="/achievements/new?successName=#{available_success}"
| Add a new "#{available_success}"
table.table
thead
tr
th Name
th Success Name
th Icon
th Parameters
th
tbody
- achievements.each do |achievement|
tr
td=achievement.name
td=achievement.success_name
td==achievement.medal
td
- achievement.parameters.each.with_index do |param_tuple, index|
strong
| #{param_tuple[0]}
| :
| &nbsp;#{param_tuple[1]}
- if index != achievement.parameters.size
br
td
form method="POST" action="/achievements/delete"
== csrf_tag
input type="hidden" name="id" value=achievement[:id]
button.btn.btn-danger type="submit" onclick="return confirm('Confirm to permanently REMOVE \'#{achievement[:name]}\' ?')"
| x

View File

@ -3,42 +3,22 @@
table.table
tbody
- pex_by_models.each.with_index do |pex_by_model, index|
- if pex_by_model[:category] && pex_by_model[:category] != pex_by_models[index - 1][:category]
- if pex_by_model[:pex][:category] != pex_by_models[index - 1][:pex][:category]
tr.full-row-sep
td.col-12(colspan=6)
- if pex_by_model[:category].empty?
- if pex_by_model[:pex][:category].empty?
| Base
- else
= pex_by_model[:category].capitalize
tr.full-row
td.col-1
.btn.pex-editor-toggler(name=pex_by_model[:id] style="display: none;")
| ¤
.pex-editor(name=name=pex_by_model[:id])
form method="POST" action="/pexs/delete"
input type="hidden" name="id" value=pex_by_model[:id]
input type="hidden" name="type" value="-"
button.btn.btn-danger type="submit" onclick="return confirm('Confirm to permantly REMOVE \'#{pex_by_model[:name]}\' and points ?')"
| x
a.btn.btn-warning href="/pexs/update?id=#{pex_by_model[:id]}"
| u
td.col-8=pex_by_model[:name]
td.col-1.center
form method="POST" action="/"
input type="hidden" name="id" value=pex_by_model[:id]
input type="hidden" name="type" value="-"
button.btn.force-1-col type="submit" style="display: block;"
| -
td.col-1.center
=pex_by_model[:user_pexs][:amount]
td.col-1.center
form method="POST" action="/"
input type="hidden" name="id" value=pex_by_model[:id]
input type="hidden" name="type" value="+"
button.btn.force-1-col type="submit" style="display: block;"
| +
= pex_by_model[:pex][:category].capitalize
- @row_class = ""
- if pex_by_model[:pex][:hidden]
- @row_class = "bg-warning"
- if pex_by_model[:pex][:flag] == "bookmarked"
- @row_class = "bg-bookmarked"
== slim :"partials/pex_row", locals: { pex_with_amount: pex_by_model }
form method="GET" action="/pexs"
== csrf_tag
button.float-end.round.custom-valid
| +

View File

@ -2,49 +2,74 @@ doctype html
html lang="en"
head
/! Required meta tags
title Life Pex
title
| Life Pex
meta charset="utf-8" /
meta content="width=device-width, initial-scale=1" name="viewport" /
/! Bootstrap CSS
link crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css" integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6" rel="stylesheet" /
link href="/css/bootstrap-override.css" rel="stylesheet"
link href="/css/main.css" rel="stylesheet"
link rel="shortcut icon" type="image/png" href="/img/favicon.png" /
body
header.bg-dark
nav.navbar.navbar-expand
nav.navbar.navbar-expand-sm.navbar-dark.bg-dark
.container-fluid
ul.navbar-nav
- if logged_in?
- if cookies["date"] == "yesterday"
a.navbar-brand href="/"
| Yesterday
- if cookies["show_full_date"] == "on"
.smaller= get_user_date
- else
a.navbar-brand href="/"
| Today
- if cookies["show_full_date"] == "on"
.smaller= get_user_date
button.navbar-toggler type="button" data-bs-toggle="collapse" data-bs-target="#navbar-collapser" aria-controls="navbar-collapser" aria-expanded="false" aria-label="Toggle navigation"
span.navbar-toggler-icon/
.collapse.navbar-collapse#navbar-collapser
ul.navbar-nav.me-auto.mb-2.mb-lg-0
- if logged_in?
- if cookies["date"] == "yesterday"
li.nav-item
a.btn.btn-lg.btn-dark href="/today" Today
li.nav-item
a.btn.btn-lg.btn-dark.active href="/" Yesterday
- else
li.nav-item
a.btn.btn-lg.btn-dark.active href="/" Today
li.nav-item
a.btn.btn-lg.btn-dark href="/yesterday" Yesterday
li.nav-item
a.btn.btn-lg.btn-dark href="/recap" Recap
li.nav-item.dropdown
a.btn.btn-lg.btn-dark.nav-link.dropdown-toggle#nav-drop-more href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false"
| More
ul.dropdown-menu aria-labelledby="nav-drop-more"
li
a.btn.btn-lg.btn-dark.position-relative href="/recap"
| Recap
- if (recalls_count = current_user.recalls_not_validated.count) > 0
span.position-absolute.top-0.start-100.translate-middle.badge.rounded-pill.bg-danger
= recalls_count
li.nav-item
a.btn.btn-lg.btn-dark href="/achievements" Achievements
li.nav-item
a.btn.btn-lg.btn-dark href="/recalls" Recalls
li.nav-item
a.btn.btn-lg.btn-dark href="/password" Change password
li
li.nav-item
a.btn.btn-lg.btn-dark href="/about" About lifepex
li
a.btn.btn-lg.btn-dark href="/logout" Logout
li.nav-item
a.btn.btn-lg.btn-dark href="/preferences" Profil preference
li.nav-item
a.btn.btn-lg.btn-dark href="/?filter_hidden=false" Show hidden
li.nav-item
form method="POST" action="/logout"
input.btn.btn-lg.btn-dark type="submit" value="Logout"
- else
li.nav-item
a.btn.btn-lg.btn-dark href="/login" Login
li.nav-item
a.btn.btn-lg.btn-dark href="/register" Register
li.nav-item
a.btn.btn-lg.btn-dark href="/about" About lifepex
.flash
#flash.flash
- if defined? flash
- flash.each do |flash_name, flash_message|
.alert.alert-dismissible.fade.show role="alert" class="alert-#{flash_name}"
@ -57,6 +82,8 @@ html lang="en"
.footer
.container-sm
script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"
/ script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"
script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"
script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js" integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf" crossorigin="anonymous"
script src="/js/error.js"
script src="/js/ajax.js"

View File

@ -1,6 +1,7 @@
main.col-md-9.ms-sm-auto.col-lg-10.px-md-4
h1 Sign in with your account
form.col-md-6 method="POST" action="/login"
form#login.col-md-6 method="POST" action="/login"
== csrf_tag
.form-group.row
label.col-sm-2.col-form-label for="inputUsername" Username
input#inputUsername.form-control.form-control-lg name="username" type="text" /

View File

@ -0,0 +1,53 @@
tr.full-row(class=@row_class)
td.col-1
.btn.pex-editor-toggler(name=pex_with_amount[:pex][:id] style="display: none;")
| ¤
.pex-editor(name=name=pex_with_amount[:pex][:id])
form method="POST" action="/pexs/bookmark"
== csrf_tag
input type="hidden" name="id" value=pex_with_amount[:pex][:id]
button.btn.btn-success type="submit"
- if pex_with_amount[:pex][:flag] == "bookmarked"
| unfav
- else
| fav
form method="POST" action="/pexs/delete"
== csrf_tag
input type="hidden" name="id" value=pex_with_amount[:pex][:id]
input type="hidden" name="type" value="-"
button.btn.btn-danger type="submit" onclick="return confirm('Confirm to permanently REMOVE \'#{pex_with_amount[:pex][:name]}\' and points ?')"
| x
form method="POST" action="/pexs/hide"
== csrf_tag
input type="hidden" name="id" value=pex_with_amount[:pex][:id]
- if pex_with_amount[:pex][:hidden]
button.btn.btn-success type="submit"
| >>
- else
button.btn.btn-danger type="submit" onclick="return confirm('Confirm to disable \'#{pex_with_amount[:pex][:name]}\' (do not affect points) ?')"
| <<
a.btn.btn-warning href="/pexs/update?id=#{pex_with_amount[:pex][:id]}"
| u
td.col-8
.row
.col-8
| #{pex_with_amount[:pex][:name]}
.col-4
.small=pex_with_amount[:user_pexs][:last_inserted_at]
td.col-1.center
form.userpexvalidation.userpexvalidationdecrease method="POST" action="/" onsubmit="return userpexValidation(event)"
== csrf_tag
input type="hidden" name="id" value=pex_with_amount[:pex][:id]
input type="hidden" name="type" value="-"
button.btn.force-1-col type="submit" style="display: block;"
| -
td.col-1.center
.userpexvalidation.userpexvalidationvalue
=pex_with_amount[:user_pexs][:at_date]
td.col-1.center
form.userpexvalidation.userpexvalidationincrease method="POST" action="/" onsubmit="return userpexValidation(event)"
== csrf_tag
input type="hidden" name="id" value=pex_with_amount[:pex][:id]
input type="hidden" name="type" value="+"
button.btn.force-1-col type="submit" style="display: block;"
| +

View File

@ -1,6 +1,7 @@
main.col-md-9.ms-sm-auto.col-lg-10.px-md-4
h1 Change your password
form.col-md-6 method="POST" action="/password"
== csrf_tag
.form-group.row
label.col-sm-2.col-form-label for="inputPassword" Password
input#inputPassword.form-control.form-control-lg name="password" type="password" minlength="5" /

View File

@ -7,6 +7,7 @@ main.col-md-9.ms-sm-auto.col-lg-10.px-md-4
- else
| Add a new pex
form.col-md-6 method="POST" action="/pexs"
== csrf_tag
- if pex
input type="hidden" name="id" value=pex.id
.form-group.row
@ -14,7 +15,10 @@ main.col-md-9.ms-sm-auto.col-lg-10.px-md-4
input#inputUsername.form-control.form-control-lg name="name" type="text" value=pex&.name /
.form-group.row
label.col-sm-12.col-form-label for="inputCategory" Category
input#inputCategory.form-control.form-control-lg name="category" type="text" placeholder="sport" value=pex&.category /
input#inputCategory.form-control.form-control-lg name="category" type="text" placeholder="sport" list="categoryList" value=pex&.category /
datalist#categoryList
- categories.each do |category|
option value=category
.form-group.row
label.col-sm-12.col-form-label for="inputAmount" Xp Amount by check
input#inputAmount.form-control.form-control-lg name="amount" type="integer" min="-50" max="50" value=(pex&.amount||"1") /

View File

@ -0,0 +1,45 @@
.container
h1
| Profil preferences
h2
| Recap
form(method="POST")
== csrf_tag
/-
- USER_PREFERENCES.each do |param_name, upc|
- if upc.html[:type] == "checkbox"
.form-group.form-check.form-switch
label.col-sm-12.form-check-label for=param_name
strong=upc.description
- if cookies[upc.cookie] == "on"
input.form-check-input *upc.html name=param_name checked="on" /
- else
input.form-check-input *upc.html name=param_name /
- else
.form-group.row
label.col-sm-12.col-form-label for=param_name
strong=upc.description
input.form-control.form-control-lg *upc.html name=param_name value=cookies[upc.cookie] /
.form-group.row
input.btn.btn-lg.btn-block type="submit" value="Update"
- if LifePex::APP_ENV == "debug"
h2
| Debug
p
table
- cookies.each do |k, v|
tr
td= "cookies.#{k}"
td= v
tr
td
| get_user_date
td= get_user_date
h2
| Private data export
a.btn.btn-success(href="/api/user-pex/v1/export.csv")
| Export as CSV

View File

@ -0,0 +1,33 @@
main.col-md-9.ms-sm-auto.col-lg-10.px-md-4
h1
| Add a new Recall
form.col-md-6 method="POST" action="/recalls"
== csrf_tag
.form-group.row
label.col-sm-12.col-form-label for="name"
strong
| Name
input#name.form-control.form-control-lg name="name" type="text" /
.form-group.row
label.col-sm-12.col-form-label for="span_duration"
strong
| Span duration
input#span_duration.form-control.form-control-lg name="span_duration" type="number" value="7" /
.form-group.row
label.col-sm-12.col-form-label for="repeated"
strong
| Repeated
input#inputRepeated.form-control.form-control-lg name="repeated" type="number" value="1" /
.form-group.row
label.col-sm-12.col-form-label for="pex_id"
strong
| PexId
select.form-select.form-control-lg arial-label="Pex Id" name="pex_id"
- pexs.each.with_index do |pex, pex_idx|
- if pex_idx == 0
option value=pex[:id] selected="true" =pex[:name]
- else
option value=pex[:id] =pex[:name]
.form-group.row
input.btn.btn-lg.btn-block type="submit" value="create"

25
src/views/recalls.slim Normal file
View File

@ -0,0 +1,25 @@
h1
| Current recalls
a.btn.btn-dark href="/recalls/new"
| Add a new recall
table.table
thead
tr
th Name
th Span duration
th Repeated
th
tbody
- recalls.each do |recall|
tr
td=recall.name
td=recall.span_duration
td=recall.repeated
td
form method="POST" action="/recalls/delete"
== csrf_tag
input type="hidden" name="id" value=recall[:id]
button.btn.btn-danger type="submit" onclick="return confirm('Confirm to permanently REMOVE \'#{recall[:name]}\' ?')"
| x

View File

@ -2,6 +2,17 @@ script src="https://code.highcharts.com/highcharts.js"
h1 Recap
.recap-recalls
- if !recalls_not_validated.empty?
p
| You have a warning to consider!
h2
- recalls_not_validated.each do |recall|
span.badge.bg-danger
| #{recall[:name]}
br/
.recap-level
span.badge.bg-primary
| Level #{level.current_level.round}
@ -10,6 +21,23 @@ h1 Recap
| #{level.xp_from_current_level.round} / #{level.xp_for_complete_level.round}
.recap-xp
.float-end
form#reloader.col-md-6 method="GET"
== csrf_tag
.form-group.row
label.col-form-label for="inputDaysAgo" Load how many days since today ?
input#inputDaysAgo.form-control.form-control-lg name="days_ago" type="number" min="3" max="365" step="1" value=(params["days_ago"] || cookies["recap_days"] || "60") /
.form-group.row
p
input.btn.btn-lg.btn-block type="submit" value="Reload"
#recap-xp-container style="width:100%; height:400px;"
.recap-success
- if defined?(medals)
- medals.each do |medal|
p
| You have the achievement "#{medal.name}" &nbsp;
== medal.medal
| &nbsp;!
script src="/js/recap.js"

View File

@ -1,8 +1,9 @@
main.col-md-9.ms-sm-auto.col-lg-10.px-md-4
h1 Register a new account
p.bg-warning
| Warning: There is no password recovery or change possible yet
| Warning: There is no password recovery. Don't loose it.
form.col-md-6 method="POST" action="/register"
== csrf_tag
.form-group.row
label.col-sm-2.col-form-label for="inputUsername" Username
input#inputUsername.form-control.form-control-lg name="username" type="text" /

82
test/base.rb Normal file
View File

@ -0,0 +1,82 @@
ENV["LIFEPEX_ENV"] = "test"
require "yaml"
require_relative "./fixtures_reader"
require_relative "../src/app"
require "rack/test"
require "test/unit"
class LifePexTest < Test::Unit::TestCase
include Rack::Test::Methods
def app
LifePex::App
end
%i(get head).each do |verb|
define_method "api_#{verb}" do |uri, **headers|
self.send(verb, uri, headers.merge({ "CONTENT_TYPE" => "application/json" }))
end
end
%i(post put patch delete).each do |verb|
define_method "api_#{verb}" do |uri, body = {}, **headers|
header "Accept", "application/json"
header "Content-Type", "application/json"
self.send(verb, uri, body.to_json, headers.merge({
"CONTENT_TYPE" => "application/json",
}))
end
end
def login_as(username = "test", password = "test")
api_post '/api/user/v1/login', { username: username, password: password }
end
# check if every key of `minimal_data` is contained in `full_data`
def assert_hash_include(minimal_data, full_data)
full_data = full_data.stringify_keys
minimal_data.stringify_keys.each do |key, value|
assert_equal value, full_data[key], "#{key} should contains <#{value}>, got <#{full_data[key]}>"
end
end
# parse the body as a json
def api_reponse_body
JSON.parse last_response.body
end
# check if the body contains a message
def assert_message_exist
assert_not_empty api_reponse_body.dig "message"
end
# check if the entity.user_id belongs to the user who sent the last req
def assert_entity_owned(entity)
user_id = JWT.decode(last_request.cookies["auth"], nil, false).first["user_id"]
assert_equal user_id.to_s, entity["user_id"].to_s
end
# status may be a Regexp: the assertion with test if they match
# status may be a String: the assertion with test if the param includes the response status
# status may be another thing: the assertion with test equality
def assert_status(status)
if status.is_a?(Regexp)
assert_match status, last_response.status.to_s
elsif status.is_a?(String)
assert_equal status, last_response.status.to_s[0..(status.size)]
else
assert_equal status, last_response.status
end
end
def assert_client_error(status = /^4\d\d/)
refute last_response.ok?
assert_status status
end
def assert_server_error(status = /^5\d\d/)
refute last_response.ok?
assert_status status
end
end

6
test/fixtures/pex_base.yaml vendored Normal file
View File

@ -0,0 +1,6 @@
pexs:
- id: 1
user_id: 1
name: 'pex_test_1'
category: 'category'
amount: 1.234

7
test/fixtures/user_base.yaml vendored Normal file
View File

@ -0,0 +1,7 @@
users:
- id: 1
username: "test"
hashed_password: "$2a$12$yWy1fyQBTGYfwRY7H8QGAubHS3nJiMWAaHl8HhXZcTZnuQxRlqIhu" # test
- id: 2
username: "testbis"
hashed_password: "$2a$12$yWy1fyQBTGYfwRY7H8QGAubHS3nJiMWAaHl8HhXZcTZnuQxRlqIhu" # test

23
test/fixtures_reader.rb Normal file
View File

@ -0,0 +1,23 @@
require "yaml"
class FixtureReader
def initialize
cleanup!
end
KEEP_TABLES = %i(meta)
def apply!(id)
path = File.join("test", "fixtures", "#{id}.yaml")
data = YAML.load_file path
data.each do |table, rows|
rows.each { |row| LifePex::DB[table.to_sym].insert row }
end
end
def cleanup!
LifePex::DB.tables.reject { |table, _| KEEP_TABLES.include?(table) }.each do |table|
LifePex::DB[table].truncate
end
end
end

149
test/pex2_system_test.rb Normal file
View File

@ -0,0 +1,149 @@
require_relative "./base"
class Pex2SystemTest < LifePexTest
def setup
@fixture ||= FixtureReader.new # should be in startup
@fixture.apply! "user_base"
end
def cleanup
@fixture.cleanup!
end
private def login_as(username = "test", password = "test")
api_post '/api/user/v1/login', { username: username, password: password }
end
def test_pex_life_cycle
login_as
assert last_response.ok?
api_get "/api/pex/v2/pexs"
assert last_response.ok?
assert_empty api_reponse_body
pex_input = { name: "pex1", category: "cat1", amount: 1.23 }
api_post "/api/pex/v2/pexs", pex_input
assert last_response.ok?, "A new pex should have been added"
assert_message_exist
assert_hash_include pex_input, api_reponse_body["pex"]
assert_entity_owned api_reponse_body["pex"]
pex_id = api_reponse_body.dig "pex", "id"
api_get "/api/pex/v2/pexs"
assert last_response.ok?
assert_equal 1, api_reponse_body.size, "A new pex should exist"
api_get "/api/pex/v2/pexs/#{pex_id}"
assert last_response.ok?
assert_hash_include pex_input, api_reponse_body["pex"]
assert_entity_owned api_reponse_body["pex"]
api_get "/api/pex/v2/pexs/#{pex_id}/more"
assert last_response.ok?
assert_hash_include pex_input, api_reponse_body["pex"]
assert_entity_owned api_reponse_body["pex"]
end
def test_pex_errors
api_post "/api/pex/v2/pexs", { name: 'pex1', amount: 1.23 }
assert_client_error 401 # not connect
login_as
assert last_response.ok?
api_post "/api/pex/v2/pexs", { }
assert_client_error 400 # missing name
api_post "/api/pex/v2/pexs", { amount: 1.23 }
assert_client_error 400 # missing name
api_post "/api/pex/v2/pexs", { name: 'pex1' }
assert_client_error 400 # missing amount
api_post "/api/pex/v2/pexs", { name: 'pex1', amount: 1.23 }
api_post "/api/pex/v2/pexs", { name: 'pex1', amount: 1.23 }
assert_client_error # duplicate
end
def test_security
login_as "test"
assert last_response.ok?
api_post "/api/pex/v2/pexs", { name: 'pex1', amount: 1.23, category: "cat1" }
assert last_response.ok?
pex_id = api_reponse_body.dig "pex", "id"
login_as "testbis"
assert last_response.ok?
api_get "/api/pex/v2/pexs"
api_get "/api/pex/v2/pexs"
assert last_response.ok?
assert_empty api_reponse_body
api_get "/api/pex/v2/pexs/#{pex_id}"
assert_client_error
api_get "/api/pex/v2/pexs/#{pex_id}/more"
assert_client_error
api_put "/api/pex/v2/pexs/#{pex_id}", {}
assert_client_error
api_delete "/api/pex/v2/pexs/#{pex_id}"
assert_client_error
api_post "/api/pex/v2/pexs/#{pex_id}/recap", {}
assert_client_error
end
def test_pex_validation_and_counts
login_as
assert last_response.ok?
api_post "/api/pex/v2/pexs", { name: 'pex1', amount: 1.23, category: "cat1" }
assert last_response.ok?
pex_id = api_reponse_body.dig "pex", "id"
api_post "/api/pex/v2/pexs/#{pex_id}/recap", {}
assert last_response.ok?
assert_empty api_reponse_body["user_pexs"]
end
def test_pex_update
login_as
assert last_response.ok?
api_post "/api/pex/v2/pexs", { name: 'pex1', amount: 1.23, category: "cat1" }
assert last_response.ok?
pex_id = api_reponse_body.dig "pex", "id"
api_put "/api/pex/v2/pexs/#{pex_id}", { name: 'pex2', amount: 1.25 }
assert last_response.ok?
assert_equal pex_id, api_reponse_body.dig("pex", "id")
assert_equal "pex2", api_reponse_body.dig("pex", "name")
assert_equal 1.25, api_reponse_body.dig("pex", "amount")
assert_equal "cat1", api_reponse_body.dig("pex", "category")
end
def test_pex_delete
login_as
assert last_response.ok?
api_get "/api/pex/v2/pexs"
assert last_response.ok?
assert_equal 0, api_reponse_body.size, "No pex exist yet"
api_post "/api/pex/v2/pexs", { name: 'pex1', amount: 1.23, category: "cat1" }
assert last_response.ok?
pex_id = api_reponse_body.dig "pex", "id"
api_get "/api/pex/v2/pexs"
assert last_response.ok?
assert_equal 1, api_reponse_body.size, "A pex should exists now"
api_delete "/api/pex/v2/pexs/#{pex_id}"
assert last_response.ok?
api_get "/api/pex/v2/pexs"
assert last_response.ok?
assert_equal 0, api_reponse_body.size, "Pex should be removed"
end
end

42
test/user_pex_test.rb Normal file
View File

@ -0,0 +1,42 @@
require_relative "./base"
class UserPexSystemTest < LifePexTest
def setup
@fixture ||= FixtureReader.new # should be in startup
@fixture.apply! "user_base"
@fixture.apply! "pex_base"
end
def cleanup
@fixture.cleanup!
end
def test_user_pex_counter
login_as
assert last_response.ok?
api_get "/api/user-pex/v1/pexs/1/amount/by-date/2021-01-01"
assert last_response.ok?
assert_equal 0, api_reponse_body["count"], "No user pex counted yet"
api_post "/api/user-pex/v1/pexs/1/validation", { date: "2021-01-01" }
assert last_response.ok?
assert_equal "2021-01-01", api_reponse_body.dig("user_pex", "created_at")
assert_equal 1, api_reponse_body.dig("user_pex", "pex_id")
assert_equal 1, api_reponse_body.dig("user_pex", "user_id")
api_get "/api/user-pex/v1/pexs/1/amount/by-date/2021-01-01"
assert last_response.ok?
assert_equal 1, api_reponse_body["count"], "One use pex is added"
api_delete "/api/user-pex/v1/pexs/1/validation", { date: "2021-01-01" }
assert last_response.ok?
api_get "/api/user-pex/v1/pexs/1/amount/by-date/2021-01-01"
assert last_response.ok?
assert_equal 0, api_reponse_body["count"], "No user pex remaining"
api_delete "/api/user-pex/v1/pexs/1/validation", { date: "2021-01-01" }
assert_client_error
end
end

37
test/user_system_test.rb Normal file
View File

@ -0,0 +1,37 @@
require_relative "./base"
class UserSystemTest < LifePexTest
def setup
@fixture ||= FixtureReader.new # should be in startup
@fixture.apply! "user_base"
end
def cleanup
@fixture.cleanup!
end
def test_login_generates_cookie
data = { username: 'test', password: 'test' }
api_post '/api/user/v1/login', data
assert last_response.ok?
assert_not_empty last_response.headers["Set-Cookie"]
end
def test_register_and_login
data = { username: 'test2', password: 'test' }
api_post '/api/user/v1/register', data
assert last_response.ok?
assert_not_empty last_response.headers["Set-Cookie"]
assert_match (/auth=[\-\.\w]+; domain=example.org; path=\//), last_response.headers["Set-Cookie"]
end
def test_register_refuse_duplicate
data = { username: 'test2', password: 'test' }
api_post '/api/user/v1/register', data
assert last_response.ok?
data = { username: 'test2', password: 'test' }
api_post '/api/user/v1/register', data
refute last_response.ok?
end
end