Compare commits

..

No commits in common. "develop" and "v0.17.2" have entirely different histories.

51 changed files with 341 additions and 1773 deletions

View File

@ -1,13 +0,0 @@
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

View File

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

2
.gitignore vendored
View File

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

37
Gemfile
View File

@ -3,34 +3,25 @@
source "https://rubygems.org" source "https://rubygems.org"
# web # web
gem "puma", "~> 5" gem "puma", "~> 5.3"
gem "sinatra", "~> 2" gem "sinatra", "~> 2.1"
gem "sinatra-contrib", "~> 2" gem "sinatra-contrib", "~> 2.1"
gem "slim", "~> 4" gem "slim", "~> 4.1"
# database # database
gem "sequel", "~> 5" # you # comment what you don't want
# you # comment the drivers you don't want gem "sequel", "~> 5.43"
gem "sqlite3", "~> 1" gem "sqlite3", "~> 1.4"
# gem "pg", "~> 1.2" gem "pg", "~> 1.2"
# security # security
gem "jwt", "~> 2" gem "jwt", "~> 2.2"
gem "bcrypt", "~> 3" gem "bcrypt", "~> 3.1"
gem "rack_csrf", "~> 2" gem "rack_csrf", "~> 2.6"
# api tools # api tools
gem "doc_my_routes" gem "doc_my_routes"
# debug and helpers # debug tools
gem "colorize", "~> 0.8" gem "pry", "~> 0.14.1"
gem "activesupport", "~> 6" gem "colorize", "~> 0.8.1"
# 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,81 +1,64 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
activesupport (6.1.6.1) bcrypt (3.1.16)
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) coderay (1.1.3)
colorize (0.8.1) colorize (0.8.1)
concurrent-ruby (1.1.10)
doc_my_routes (0.13.0) doc_my_routes (0.13.0)
dotenv (2.8.1) jwt (2.2.3)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
jwt (2.4.1)
method_source (1.0.0) method_source (1.0.0)
minitest (5.16.2)
multi_json (1.15.0) multi_json (1.15.0)
mustermann (2.0.2) mustermann (1.1.1)
ruby2_keywords (~> 0.0.1) ruby2_keywords (~> 0.0.1)
nio4r (2.5.8) nio4r (2.5.7)
pg (1.2.3)
pry (0.14.1) pry (0.14.1)
coderay (~> 1.1) coderay (~> 1.1)
method_source (~> 1.0) method_source (~> 1.0)
puma (5.6.4) puma (5.3.2)
nio4r (~> 2.0) nio4r (~> 2.0)
rack (2.2.4) rack (2.2.3)
rack-protection (2.2.2) rack-protection (2.1.0)
rack rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack_csrf (2.6.0) rack_csrf (2.6.0)
rack (>= 1.1.0) rack (>= 1.1.0)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.4)
sequel (5.58.0) sequel (5.43.0)
sinatra (2.2.2) sinatra (2.1.0)
mustermann (~> 2.0) mustermann (~> 1.0)
rack (~> 2.2) rack (~> 2.2)
rack-protection (= 2.2.2) rack-protection (= 2.1.0)
tilt (~> 2.0) tilt (~> 2.0)
sinatra-contrib (2.2.2) sinatra-contrib (2.1.0)
multi_json multi_json
mustermann (~> 2.0) mustermann (~> 1.0)
rack-protection (= 2.2.2) rack-protection (= 2.1.0)
sinatra (= 2.2.2) sinatra (= 2.1.0)
tilt (~> 2.0) tilt (~> 2.0)
slim (4.1.0) slim (4.1.0)
temple (>= 0.7.6, < 0.9) temple (>= 0.7.6, < 0.9)
tilt (>= 2.0.6, < 2.1) tilt (>= 2.0.6, < 2.1)
sqlite3 (1.4.4) sqlite3 (1.4.2)
temple (0.8.2) temple (0.8.2)
tilt (2.0.11) tilt (2.0.10)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
zeitwerk (2.6.0)
PLATFORMS PLATFORMS
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
activesupport (~> 6) bcrypt (~> 3.1)
bcrypt (~> 3) colorize (~> 0.8.1)
colorize (~> 0.8)
doc_my_routes doc_my_routes
dotenv (~> 2) jwt (~> 2.2)
jwt (~> 2) pg (~> 1.2)
pry pry (~> 0.14.1)
puma (~> 5) puma (~> 5.3)
rack-test (~> 1) rack_csrf (~> 2.6)
rack_csrf (~> 2) sequel (~> 5.43)
sequel (~> 5) sinatra (~> 2.1)
sinatra (~> 2) sinatra-contrib (~> 2.1)
sinatra-contrib (~> 2) slim (~> 4.1)
slim (~> 4) sqlite3 (~> 1.4)
sqlite3 (~> 1)
BUNDLED WITH BUNDLED WITH
2.2.16 2.2.16

111
README.md
View File

@ -1,103 +1,60 @@
# LifePex # LifePex
[![Build Status](https://drone.sceptique.eu/api/badges/Sceptique/LifePex/status.svg)](https://drone.sceptique.eu/Sceptique/LifePex) ## Dependencies
## Install the software on a server * git
* ruby 3
* sqlite or postgresql
### Install the dependencies ## Setup
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.
``` ```
# Install dependencies
bundle install bundle install
```
### Configuration setup # SQLITE
./init/database.rb sqlite://sqlite.db
A sample file contains all the variables you will need in order to start the server. # POSTGRESQL
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 USER root WITH PASSWORD 'toor' SUPERUSER;"
psql -U postgres postgres -c "CREATE DATABASE life_pex" psql -U postgres postgres -c "CREATE DATABASE life_pex"
echo LIFEPEX_DB=postgres://root:toor@localhost/life_pex >> .env.local ./init/database.rb postgres://root:toor@localhost/life_pex
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"
# export LIFEPEX_BIND="0.0.0.0" # you may want to bind against something else than 127.0.01
./src/app.rb ./src/app.rb
``` ```
### Service, update, etc. ## Generate documentation
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 ./init/doc.rb
xdg-open ./public/doc/index.html xdg-open ./public/doc/index.html
``` ```
### Testing ## Features
Generate first a specific configuration file * [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
* [x] Add advanced pex with variable value
* [ ] Improve security (token validity limit, random seed warning, ...)
``` ![recap image](https://git.sceptique.eu/attachments/ce7c2fab-bd1b-43fc-b10a-21b2c953e2c2)
cp .env.local .env.test.local ![today image](https://git.sceptique.eu/attachments/ecd7a36d-eac7-40aa-b973-c6ddbac87f51)
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`

View File

@ -1,21 +0,0 @@
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

View File

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

View File

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

View File

@ -1,13 +0,0 @@
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

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

View File

@ -1,34 +0,0 @@
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;
}

View File

@ -1,11 +0,0 @@
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,99 +17,16 @@ function toggle(node) {
} }
} }
Array.toObject = function (arr) {
return arr.reduce((base, current) => {
base[current[0]] = current[1];
return base;
}, {});
};
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(toggler);
toggler.addEventListener("click", (event) => {
toggle(pex_editor);
});
}
document.addEventListener("DOMContentLoaded", (_event) => { document.addEventListener("DOMContentLoaded", (_event) => {
const editor_togglers = document.querySelectorAll('.pex-editor-toggler'); const togglers = document.querySelectorAll('.pex-editor-toggler');
editor_togglers.forEach(setupPexEditorToggler);
const userpexvalidation0 = document.querySelectorAll('.userpexvalidationvalue').map().filter(tag => tag.textContent == "0"); togglers.forEach((t) => {
userpexvalidation0.forEach((tag) => { const name = t.attributes.name.value;
tag.parentNode.parentNode.childNodes[2].childNodes[0].hidden = true; const pex_editor = document.querySelector(`.pex-editor[name="${name}"]`);
hide(pex_editor);
show(t);
t.addEventListener("click", (event) => {
toggle(pex_editor);
});
}); });
}); });

View File

@ -1,4 +1,4 @@
function setupChart({ all_dates }, ...cumuls) { function setupChart(...cumuls) {
const chart = Highcharts.chart('recap-xp-container', { const chart = Highcharts.chart('recap-xp-container', {
chart: { chart: {
backgroundColor: '#000', backgroundColor: '#000',
@ -7,53 +7,24 @@ function setupChart({ all_dates }, ...cumuls) {
text: '', text: '',
color: '#55f5f5', color: '#55f5f5',
}, },
xAxis: { series: cumuls.map(cumul => ({
categories: all_dates,
},
series: cumuls.map((cumul, idx) => ({
// color: '#55f5f5', // color: '#55f5f5',
visible: idx != 0,
name: cumul.name, name: cumul.name,
data: cumul.data, data: cumul.data,
})), })),
}); });
} }
function objectToQueryParam(obj) { document.addEventListener('DOMContentLoaded', function () {
return Object.entries(obj).map(tuple => tuple.join("=")).join("&"); 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 urlWithQueryParams(base, object_with_params) { xhttp.open("GET", "/api/pex/v1/recap", true);
if (Object.keys(object_with_params).length > 0) { xhttp.setRequestHeader("Accept", "application/json");
return `${base}?${objectToQueryParam(object_with_params)}`; xhttp.send();
}
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();
});
}); });

View File

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

View File

@ -39,33 +39,23 @@ class LifePex::AchievementDSL::Success
# end # end
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) def format_parameters_hash(parameters)
parameters.map do |key, value| parameters.map do |key, value|
parameter = @parameters.find { |param| param.name == key } parameter = @parameters.find { |param| param.name == key }
raise RuntimeError.new "No #{key} parameter found" if parameter.nil? raise RuntimeError.new "No #{key} parameter found" if parameter.nil?
begin typed_value = if parameter.type == :Integer
typed_value = static_cast_parameter_value(value: value, type: parameter.type) value.to_i
rescue => err elsif parameter.type == :Number
raise "Invalid value \"#{value}\" for type #{parameter.type} of parameter \"#{parameter.name}\"" value.to_f
end elsif parameter.type == :Array
value.split(',')
elsif parameter.type == :ArrayOfNumber
value.split(',').map(&:to_f)
elsif parameter.type == :ArrayOfInteger
value.split(',').map(&:to_i)
else
value
end
[key, typed_value] [key, typed_value]
end.to_h end.to_h
end end

View File

@ -1,75 +1,66 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
module LifePex require "pry"
end # require "sinatra"
require "sinatra/base" # web
require "sinatra/cookies" require "sinatra/cookies"
require "sinatra/base"
require "sinatra/namespace"
require "slim" require "slim"
require "sinatra/namespace" # api require "bcrypt"
require "doc_my_routes" # api doc
require "bcrypt" # security
require "jwt" require "jwt"
require "sequel"
require "json"
require "doc_my_routes"
require "rack/csrf" require "rack/csrf"
require "securerandom" 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/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 module LifePex
DB = Sequel.connect ENV["LIFEPEX_DB"] DB = Sequel.connect ARGV[0] || ENV["LIFEPEX_DB"] || "sqlite://sqlite.db"
BASE_URL = ENV["LIFEPEX_BASE_URL"] || "http://localhost:4567" BASE_URL = ARGV[1] || ENV["LIFEPEX_BASE_URL"] || "http://localhost:4567"
SECRET = ENV["LIFEPEX_SECRET"] SECRET = ENV.fetch('LIFEPEX_SECRET') do
STDERR.puts "WARNING: LIFEPEX_SECRET will be randomly generated as a fallback"
SecureRandom.hex(64)
end
if SECRET.size < 8
STDERR.puts "ERROR: Your secret is NOT very secret, fix this (at least 8 hexadigits entropy)"
exit 1
end
CODE_VERSION = DB[:meta].first[:code_version] CODE_VERSION = DB[:meta].first[:code_version]
CODE_DATE = DB[:meta].first[:code_date] CODE_DATE = DB[:meta].first[:code_date]
include LifePex::Utils::Url include LifePex::Utils::Url
module Systems module Systems
end end
end end
# Then we load all the systems require_relative "./utils/json_api.rb"
require_relative "./utils/string.rb"
Dir[File.join(__dir__, 'utils', '*.rb')].each { |file| require file }
require_relative "./achievements/dsl.rb" require_relative "./achievements/dsl.rb"
require_relative "./achievements/achievement.rb" require_relative "./achievements/achievement.rb"
Dir[File.join(__dir__, 'models', '*.rb')].each { |file| require file } require_relative "./models/level.rb"
Dir[File.join(__dir__, 'systems', '*.rb')].each { |file| require file } require_relative "./models/user.rb"
require_relative "./models/pex.rb"
require_relative "./models/user_pex.rb"
require_relative "./models/achievement.rb"
require_relative "./systems/csrf.rb"
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 "./systems/achievement.rb"
# Static file serving in this file because it is overkill to create a file for this
class LifePex::Systems::PublicSystem < Sinatra::Base class LifePex::Systems::PublicSystem < Sinatra::Base
set :public_folder, "public" set :public_folder, 'public'
end end
# Main app class LifePex::Systems::BaseSystem < Sinatra::Base
class LifePex::App < Sinatra::Base
DocMyRoutes.configure do |config| DocMyRoutes.configure do |config|
config.title = "LifePex" config.title = "LifePex"
config.description = "LifePex JSON REST API documentation" config.description = "LifePex JSON REST API documentation"
@ -78,23 +69,14 @@ class LifePex::App < Sinatra::Base
set :session_secret, LifePex::SECRET set :session_secret, LifePex::SECRET
enable :sessions enable :sessions
# use Rack::Csrf, skip: ["POST:/api/*"], :raise => (LifePex::APP_ENV != "production") if LifePex::APP_ENV != "test" use Rack::Csrf, :skip => ['*:/api/*']
set :protection, :except => :json_csrf if LifePex::APP_ENV != "test"
LifePex::Systems.constants use LifePex::Systems::AuthSystem
.filter { |system| system.to_s =~ /System$/ } use LifePex::Systems::PublicSystem
.each { |system| use LifePex::Systems::UserSystem
use LifePex::Systems.const_get(system) use LifePex::Systems::PexSystem
puts "Loaded #{system.to_s.green}" use LifePex::Systems::Pex2System
} use LifePex::Systems::Achievement
# 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 include JSON::API
not_found do not_found do
@ -105,14 +87,10 @@ class LifePex::App < Sinatra::Base
end end
end end
get "/api/meta/v1", provides: "json" do get "/api/meta/v1", provides: 'json' do
LifePex::Systems::ApiList.get_all_api_routes.to_json LifePex::Systems::ApiList.get_all_api_routes.to_json
end end
set :bind, ENV["LIFEPEX_BIND"] || "127.0.0.1" 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 run! if app_file == $0
end end

View File

@ -1,7 +1,6 @@
class LifePex::Pex < Sequel::Model(LifePex::DB[:pexs].order(:category, :name)) class LifePex::Pex < Sequel::Model(LifePex::DB[:pexs].order(:category, :name))
one_to_many :user_pexs one_to_many :user_pexs
many_to_one :user many_to_one :user
one_to_many :recalls
# note: wont work on #update # note: wont work on #update
def before_validation def before_validation

View File

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

View File

@ -1,23 +1,4 @@
class LifePex::User < Sequel::Model(:users) class LifePex::User < Sequel::Model(:users)
one_to_many :user_pexs one_to_many :user_pexs
one_to_many :pexs 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 end

View File

@ -1,12 +1,4 @@
class LifePex::UserPex < Sequel::Model(:user_pexs) class LifePex::UserPex < Sequel::Model(:user_pexs)
many_to_one :user many_to_one :user
many_to_one :pex 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 end

View File

@ -1,7 +1,4 @@
require_relative "./csrf.rb" class LifePex::Systems::Achievement < LifePex::Systems::AuthSystem
require_relative "./auth.rb"
class LifePex::Systems::AchievementSystem < LifePex::Systems::AuthSystem
include LifePex::Systems::CrlfHelper include LifePex::Systems::CrlfHelper
get "/achievements", auth: [] do get "/achievements", auth: [] do
@ -16,21 +13,16 @@ class LifePex::Systems::AchievementSystem < LifePex::Systems::AuthSystem
post "/achievements", auth: [] do post "/achievements", auth: [] do
success = LifePex::AchievementDSL::Includer::SUCCESS_INDEX[params["successName"]] success = LifePex::AchievementDSL::Includer::SUCCESS_INDEX[params["successName"]]
begin parameters_json = success.format_parameters_hash(params["inputParams"]).to_json
parameters_json = success.format_parameters_hash(params["inputParams"]).to_json achievement = LifePex::Achievement.new(
achievement = LifePex::Achievement.new( user_id: current_user_id,
user_id: current_user_id, success_name: params["successName"],
success_name: params["successName"], name: params["inputName"],
name: params["inputName"], icon: params["inputMedal"],
icon: params["inputMedal"], parameters_json: parameters_json,
parameters_json: parameters_json, ).save
).save achievements = LifePex::Achievement.where(user_id: current_user_id).all
achievements = LifePex::Achievement.where(user_id: current_user_id).all slim :achievements, locals: { achievements: achievements, flash: { success: "Added achievements '#{achievement.name}" } }
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 end
post "/achievements/delete", auth: [] do post "/achievements/delete", auth: [] do

View File

@ -1,22 +0,0 @@
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,33 +1,11 @@
class LifePex::Systems::AuthSystem < Sinatra::Base class LifePex::Systems::AuthSystem < Sinatra::Base
helpers Sinatra::Cookies helpers Sinatra::Cookies
include JSON::API 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) def user_id_decoded(cookies = nil)
cookies = cookies() if cookies.nil? cookies = cookies() if cookies.nil?
begin begin
decoded = JWT.decode(cookies["auth"], LifePex::SECRET) decoded = JWT.decode(cookies["auth"], LifePex::SECRET)
renew_user_cookie!
decoded[0]["user_id"] decoded[0]["user_id"]
rescue => err rescue => err
STDERR.puts "user_id_decoded: #{err}" STDERR.puts "user_id_decoded: #{err}"
@ -59,7 +37,7 @@ class LifePex::Systems::AuthSystem < Sinatra::Base
condition do condition do
unless logged_in? unless logged_in?
if accept_json? if accept_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 halt 303, { message: 'You need to /api/v1/register an account and /api/v1/login to get a cookie first' }.to_json
else else
redirect "/login", 303 redirect "/login", 303
end end

View File

@ -1,19 +1,35 @@
require "date" 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 class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
include JSON::API
include LifePex::Systems::CrlfHelper include LifePex::Systems::CrlfHelper
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
get "/today", auth: [] do get "/today", auth: [] do
cookies.set "date", { value: "now", httponly: false } cookies["date"] = "today"
redirect "/" redirect "/"
end end
get "/yesterday", auth: [] do get "/yesterday", auth: [] do
cookies.set "date", { value: "yesterday", httponly: false } cookies["date"] = "yesterday"
redirect "/" redirect "/"
end end
@ -21,18 +37,13 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
user_pexs = my_user_pexs(cookies, get_user_date) user_pexs = my_user_pexs(cookies, get_user_date)
user_pexs_amounts = user_pexs.group_by { |user_pex| user_pex[:pex_id] } user_pexs_amounts = user_pexs.group_by { |user_pex| user_pex[:pex_id] }
pexs = LifePex::Pex.where(user_id: current_user_id) pexs = LifePex::Pex.where(user_id: current_user_id)
pexs = pexs.where(hidden: false) if filter_hidden pexs = pexs.where(hidden: false) if filter_hidden
pexs = pexs.all pexs = pexs.all
user_pexs_last_inserted_at = LifePex::UserPex.last_inserted_at(current_user_id).all
pex_by_models = pexs.map do |pex| pex_by_models = pexs.map do |pex|
{ {
pex: pex, **pex.values,
user_pexs: { user_pexs: {
at_date: (user_pexs_amounts[pex[:id]] || []).length, amount: (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 end
@ -58,22 +69,12 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
end end
get "/pexs", auth: [] do get "/pexs", auth: [] do
categories = LifePex::Pex slim :pex_form
.select(:category)
.where(user_id: current_user_id)
.distinct
.pluck(:category)
slim :pex_form, locals: { categories: categories }
end end
get "/pexs/update", auth: [] do get "/pexs/update", auth: [] do
pex = LifePex::Pex.where(id: params["id"]).first pex = LifePex::Pex.where(id: params["id"]).first
categories = LifePex::Pex slim :pex_form, locals: { pex: pex, submit_name: "update" }
.select(:category)
.where(user_id: current_user_id)
.distinct
.pluck(:category)
slim :pex_form, locals: { pex: pex, categories: categories, submit_name: "update" }
end end
post "/pexs", auth: [] do post "/pexs", auth: [] do
@ -115,28 +116,19 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
end end
end end
def get_recap_infos(params, cookies) def get_recap_infos(cookies)
user_pexs = my_user_pexs(cookies) user_pexs = my_user_pexs(cookies)
pexs = LifePex::Pex.setup_user_pexs(user_id: current_user_id, 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 } total_xp = pexs.values.sum { |up| up[:total_by_date].values.sum }
level = LifePex::Level.new total_xp level = LifePex::Level.new total_xp
all_dates = user_pexs.map(&:created_at).uniq
all_category = pexs.values.map(&:category).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 pex_table_by_date = all_dates.map { |date| [date, 0] }.to_h
pexs.values.each do |pex| pexs.values.each do |pex|
pex[:total_by_date].each do |date, total| pex[:total_by_date].each do |date, total|
# pex_table_by_date[date] ||= 0 # pex_table_by_date[date] ||= 0
pex_table_by_date[date] += total if pex_table_by_date[date] pex_table_by_date[date] += total
end end
end end
@ -152,7 +144,7 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
category = pex[:category] category = pex[:category]
# pex_table_by_category_and_date[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] ||= 0
pex_table_by_category_and_date[category][date] += total if pex_table_by_category_and_date[category][date] pex_table_by_category_and_date[category][date] += total
end end
end end
@ -167,15 +159,6 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
} }
end.unshift(highchart_total) 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) all_setup = LifePex::Achievement.where(user_id: current_user_id).map(&:to_dsl_setup)
medals = all_setup.filter do |success| medals = all_setup.filter do |success|
success.call(user_pexs, pexs.values) success.call(user_pexs, pexs.values)
@ -183,16 +166,13 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
{ {
pex_tables: highchart_tables, pex_tables: highchart_tables,
start_date: start_date,
all_dates: all_dates,
level: level, level: level,
medals: medals, medals: medals,
recalls_not_validated: recalls_not_validated,
} }
end end
get "/recap", auth: [], provides: 'html' do get "/recap", auth: [], provides: 'html' do
recap_infos = get_recap_infos(params, cookies) recap_infos = get_recap_infos(cookies)
slim :recap, locals: recap_infos slim :recap, locals: recap_infos
end end
@ -203,10 +183,9 @@ class LifePex::Systems::PexSystem < LifePex::Systems::AuthSystem
produces 'application/json' produces 'application/json'
status_codes [200] status_codes [200]
get "/recap", auth: [], provides: 'json' do get "/recap", auth: [], provides: 'json' do
recap_infos = get_recap_infos(params, cookies) recap_infos = get_recap_infos(cookies)
{ {
pex_tables: recap_infos[:pex_tables], pex_tables: recap_infos[:pex_tables],
all_dates: recap_infos[:all_dates],
level: recap_infos[:level].to_h, level: recap_infos[:level].to_h,
}.to_json }.to_json
end end

View File

@ -2,7 +2,6 @@ require_relative "./pex.rb"
class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
# include JSON::API # included by PexSystem # include JSON::API # included by PexSystem
include LifePex::Systems::ApiResponse
# extend DocMyRoutes::Annotatable # included by PexSystem # extend DocMyRoutes::Annotatable # included by PexSystem
# register Sinatra::Namespace # included by PexSystem # register Sinatra::Namespace # included by PexSystem
@ -25,7 +24,7 @@ class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
produces 'application/json' produces 'application/json'
status_codes [200] status_codes [200]
parameter :pluck, required: false, type: 'string', in: 'query', description: 'improve performance by only fetching one field' 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 = current_user.pexs
pexs = pexs.select(json_params["pluck"]) if json_params["pluck"] pexs = pexs.select(json_params["pluck"]) if json_params["pluck"]
pexs.to_json pexs.to_json
@ -38,15 +37,10 @@ class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
parameter :category, required: true, type: 'string', in: 'body' parameter :category, required: true, type: 'string', in: 'body'
parameter :amount, required: true, type: 'number', in: 'body' parameter :amount, required: true, type: 'number', in: 'body'
parameter :auth, required: true, type: 'string', in: 'cookies' parameter :auth, required: true, type: 'string', in: 'cookies'
post '', auth: [], provides: 'json' do post "/", auth: [], provides: 'json' do
halt 400, "\"name\" is required" unless json_params["name"] raise RunetimeError.new "\"name\" is required" unless json_params["name"]
halt 400, "\"category\" is required" unless json_params["category"] raise RunetimeError.new "\"category\" is required" unless json_params["category"]
halt 400, "\"amount\" is required" unless json_params["amount"] raise RunetimeError.new "\"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( pex_id = LifePex::Pex.insert(
name: json_params['name'], name: json_params['name'],
category: json_params['category'], category: json_params['category'],
@ -59,148 +53,54 @@ class LifePex::Systems::Pex2System < LifePex::Systems::PexSystem
}.to_json }.to_json
end 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' summary 'Show the state of a pex'
produces 'application/json' produces 'application/json'
status_codes [200] status_codes [200]
parameter :id, required: true, type: 'string', in: 'path' parameter :id, required: true, type: 'string', in: 'path'
get '/:id', auth: [], provides: 'json' do 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, pex: LifePex::Pex.find(
id: params["id"],
user_id: current_user.id,
),
}.to_json }.to_json
end end
summary 'Show the state of a pex and load advanced infos about it' summary 'Show the state of a pex and load advanced infos about it'
produces 'application/json,text/html' produces 'application/json'
status_codes [200] status_codes [200]
parameter :id, required: true, type: 'string', in: 'path' 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( pex = LifePex::Pex.find(
id: params["id"], id: params["id"],
user_id: current_user_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, pex: pex,
user_pexs: { 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 }
by_date: user_pexs_by_date, }.to_json
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 end
summary 'Get the amount of a pex each day since first occurence since a given date' summary 'Get the amount of a pex each day since first occurence since 1 january'
notes '' notes ""
produces 'application/json' produces 'application/json'
status_codes [200] status_codes [200]
parameter :id, required: true, type: 'integer', in: 'path' parameter :id, required: true, type: 'integer', in: 'path'
parameter :auth, required: true, type: 'string', in: 'cookies' parameter :auth, required: true, type: 'string', in: 'cookies'
parameter :days_ago, required: false, type: 'integer', in: 'body', description: "default: 30, limit the amount of results to the n last days included" post "/:id/recap", auth: [], provides: 'json' do
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" user_id = user_id_decoded
post '/:id/recap', auth: [], provides: 'json' do
pex_id = params["id"] pex_id = params["id"]
pex = LifePex::Pex.find(id: pex_id, user_id: current_user_id) year = Date.today.year
halt 404, { message: "No pex #{params["id"]}" }.to_json if pex.nil? first_jan = Date.new(year, 1, 1)
pex = LifePex::Pex.find(id: pex_id)
start_date = 30.days.ago user_pexs = LifePex::UserPex.where {
if params["days_ago"] self.user_id == user_id && self.pex_id == pex_id && created_at > first_jan
start_date = params["days_ago"].to_i.days.ago }.first
elsif params["since_first_jan"]
year = Date.today.year
first_jan = Date.new(year, 1, 1)
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 { pex: pex, user_pexs: user_pexs }.to_json
end end
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 end
include LifePex::Systems::ApiList include LifePex::Systems::ApiList
end end

View File

@ -1,47 +0,0 @@
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,8 +1,7 @@
require "yaml" require "yaml"
require_relative "./auth.rb"
require_relative "./csrf.rb"
class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
include JSON::API
include LifePex::Systems::CrlfHelper include LifePex::Systems::CrlfHelper
DEFAULT_PEXS_FOR_NEW_USERS = YAML.load_file "config/default_pexs_for_new_users.yaml" DEFAULT_PEXS_FOR_NEW_USERS = YAML.load_file "config/default_pexs_for_new_users.yaml"
@ -11,10 +10,14 @@ class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
slim :login slim :login
end end
def setup_user_cookie!(user_id)
cookies["auth"] = JWT.encode({ "user_id" => user_id }, LifePex::SECRET)
end
def login(params) def login(params)
user = LifePex::User.where(username: params["username"]).first user = LifePex::User.where(username: params["username"]).first
if user && BCrypt::Password.new(user[:hashed_password]) == params["password"] if user && BCrypt::Password.new(user[:hashed_password]) == params["password"]
setup_user_cookie!(user[:id]) cookies["auth"] = JWT.encode({ "user_id" => user[:id] }, LifePex::SECRET)
user user
else else
nil nil
@ -23,6 +26,7 @@ class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
post "/login", provides: 'html' do post "/login", provides: 'html' do
if user = login(params) if user = login(params)
cookies["auth"] = JWT.encode({ "user_id" => user[:id] }, LifePex::SECRET)
redirect "/" redirect "/"
else else
slim :login, locals: { flash: { danger: "Failed to login" } } slim :login, locals: { flash: { danger: "Failed to login" } }
@ -56,7 +60,7 @@ class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
end end
end end
post "/logout", auth: [] do get "/logout", auth: [] do
cookies.delete "auth" cookies.delete "auth"
slim :logout, locals: { flash: { success: "Logged out" } } slim :logout, locals: { flash: { success: "Logged out" } }
end end
@ -83,40 +87,6 @@ class LifePex::Systems::UserSystem < LifePex::Systems::AuthSystem
slim :about slim :about
end 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 extend DocMyRoutes::Annotatable
register Sinatra::Namespace register Sinatra::Namespace
namespace '/api/user/v1' do namespace '/api/user/v1' do

View File

@ -1,138 +0,0 @@
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

View File

@ -1,79 +0,0 @@
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

View File

@ -1,11 +0,0 @@
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,5 +1,3 @@
require "active_support/all"
module JSON::API module JSON::API
def json_params def json_params
begin begin
@ -12,103 +10,7 @@ module JSON::API
end end
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? def accept_json?
accept? 'application/json' request.accept.any? { |a| a.entry == '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
end end

View File

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

View File

@ -3,19 +3,66 @@
table.table table.table
tbody tbody
- pex_by_models.each.with_index do |pex_by_model, index| - pex_by_models.each.with_index do |pex_by_model, index|
- if pex_by_model[:pex][:category] != pex_by_models[index - 1][:pex][:category] - if pex_by_model[:category] && pex_by_model[:category] != pex_by_models[index - 1][:category]
tr.full-row-sep tr.full-row-sep
td.col-12(colspan=6) td.col-12(colspan=6)
- if pex_by_model[:pex][:category].empty? - if pex_by_model[:category].empty?
| Base | Base
- else - else
= pex_by_model[:pex][:category].capitalize = pex_by_model[:category].capitalize
- @row_class = "" - @row_class = ""
- if pex_by_model[:pex][:hidden] - if pex_by_model[:hidden]
- @row_class = "bg-warning" - @row_class = "bg-warning"
- if pex_by_model[:pex][:flag] == "bookmarked" - if pex_by_model[:flag] == "bookmarked"
- @row_class = "bg-bookmarked" - @row_class = "bg-bookmarked"
== slim :"partials/pex_row", locals: { pex_with_amount: pex_by_model } tr.full-row(class=@row_class)
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/bookmark"
== csrf_tag
input type="hidden" name="id" value=pex_by_model[:id]
button.btn.btn-success type="submit"
- if pex_by_model[:flag] == "bookmarked"
| unfav
- else
| fav
form method="POST" action="/pexs/delete"
== csrf_tag
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 permanently REMOVE \'#{pex_by_model[:name]}\' and points ?')"
| x
form method="POST" action="/pexs/hide"
== csrf_tag
input type="hidden" name="id" value=pex_by_model[:id]
- if pex_by_model[:hidden]
button.btn.btn-success type="submit"
| >>
- else
button.btn.btn-danger type="submit" onclick="return confirm('Confirm to disable \'#{pex_by_model[:name]}\' (do not affect points) ?')"
| <<
a.btn.btn-warning href="/pexs/update?id=#{pex_by_model[:id]}"
| u
td.col-8=pex_by_model[:name]
td.col-1.center
- if pex_by_model[:user_pexs][:amount] > 0
form method="POST" action="/"
== csrf_tag
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="/"
== csrf_tag
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;"
| +
form method="GET" action="/pexs" form method="GET" action="/pexs"
== csrf_tag == csrf_tag

View File

@ -2,8 +2,7 @@ doctype html
html lang="en" html lang="en"
head head
/! Required meta tags /! Required meta tags
title title Life Pex
| Life Pex
meta charset="utf-8" / meta charset="utf-8" /
meta content="width=device-width, initial-scale=1" name="viewport" / meta content="width=device-width, initial-scale=1" name="viewport" /
/! Bootstrap CSS /! Bootstrap CSS
@ -20,13 +19,9 @@ html lang="en"
- if cookies["date"] == "yesterday" - if cookies["date"] == "yesterday"
a.navbar-brand href="/" a.navbar-brand href="/"
| Yesterday | Yesterday
- if cookies["show_full_date"] == "on"
.smaller= get_user_date
- else - else
a.navbar-brand href="/" a.navbar-brand href="/"
| Today | 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" 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/ span.navbar-toggler-icon/
.collapse.navbar-collapse#navbar-collapser .collapse.navbar-collapse#navbar-collapser
@ -39,27 +34,28 @@ html lang="en"
li.nav-item li.nav-item
a.btn.btn-lg.btn-dark href="/yesterday" Yesterday a.btn.btn-lg.btn-dark href="/yesterday" Yesterday
li.nav-item li.nav-item
a.btn.btn-lg.btn-dark.position-relative href="/recap" a.btn.btn-lg.btn-dark href="/recap" 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 li.nav-item
a.btn.btn-lg.btn-dark href="/achievements" Achievements a.btn.btn-lg.btn-dark href="/achievements" Achievements
li.nav-item
a.btn.btn-lg.btn-dark href="/recalls" Recalls
li.nav-item li.nav-item
a.btn.btn-lg.btn-dark href="/password" Change password a.btn.btn-lg.btn-dark href="/password" Change password
li.nav-item li.nav-item
a.btn.btn-lg.btn-dark href="/about" About lifepex a.btn.btn-lg.btn-dark href="/about" About lifepex
li.nav-item
a.btn.btn-lg.btn-dark href="/preferences" Profil preference
li.nav-item li.nav-item
a.btn.btn-lg.btn-dark href="/?filter_hidden=false" Show hidden a.btn.btn-lg.btn-dark href="/?filter_hidden=false" Show hidden
li.nav-item li.nav-item
form method="POST" action="/logout" a.btn.btn-lg.btn-dark href="/logout" Logout
input.btn.btn-lg.btn-dark type="submit" value="Logout"
/ 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 href="/password" Change password
/ li
/ a.btn.btn-lg.btn-dark href="/about" About lifepex
/ li
/ a.btn.btn-lg.btn-dark href="/logout" Logout
- else - else
li.nav-item li.nav-item
a.btn.btn-lg.btn-dark href="/login" Login a.btn.btn-lg.btn-dark href="/login" Login
@ -69,7 +65,7 @@ html lang="en"
a.btn.btn-lg.btn-dark href="/about" About lifepex a.btn.btn-lg.btn-dark href="/about" About lifepex
#flash.flash .flash
- if defined? flash - if defined? flash
- flash.each do |flash_name, flash_message| - flash.each do |flash_name, flash_message|
.alert.alert-dismissible.fade.show role="alert" class="alert-#{flash_name}" .alert.alert-dismissible.fade.show role="alert" class="alert-#{flash_name}"
@ -85,5 +81,3 @@ html lang="en"
/ 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/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="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,6 @@
main.col-md-9.ms-sm-auto.col-lg-10.px-md-4 main.col-md-9.ms-sm-auto.col-lg-10.px-md-4
h1 Sign in with your account h1 Sign in with your account
form#login.col-md-6 method="POST" action="/login" form.col-md-6 method="POST" action="/login"
== csrf_tag == csrf_tag
.form-group.row .form-group.row
label.col-sm-2.col-form-label for="inputUsername" Username label.col-sm-2.col-form-label for="inputUsername" Username

View File

@ -1,53 +0,0 @@
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

@ -15,10 +15,7 @@ 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 / input#inputUsername.form-control.form-control-lg name="name" type="text" value=pex&.name /
.form-group.row .form-group.row
label.col-sm-12.col-form-label for="inputCategory" Category label.col-sm-12.col-form-label for="inputCategory" Category
input#inputCategory.form-control.form-control-lg name="category" type="text" placeholder="sport" list="categoryList" value=pex&.category / input#inputCategory.form-control.form-control-lg name="category" type="text" placeholder="sport" value=pex&.category /
datalist#categoryList
- categories.each do |category|
option value=category
.form-group.row .form-group.row
label.col-sm-12.col-form-label for="inputAmount" Xp Amount by check 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") / input#inputAmount.form-control.form-control-lg name="amount" type="integer" min="-50" max="50" value=(pex&.amount||"1") /

View File

@ -1,45 +0,0 @@
.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

@ -1,33 +0,0 @@
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"

View File

@ -1,25 +0,0 @@
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,17 +2,6 @@ script src="https://code.highcharts.com/highcharts.js"
h1 Recap 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 .recap-level
span.badge.bg-primary span.badge.bg-primary
| Level #{level.current_level.round} | Level #{level.current_level.round}
@ -21,15 +10,6 @@ h1 Recap
| #{level.xp_from_current_level.round} / #{level.xp_for_complete_level.round} | #{level.xp_from_current_level.round} / #{level.xp_for_complete_level.round}
.recap-xp .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-xp-container style="width:100%; height:400px;"
.recap-success .recap-success

View File

@ -1,82 +0,0 @@
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

View File

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

View File

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

View File

@ -1,23 +0,0 @@
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

View File

@ -1,149 +0,0 @@
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

View File

@ -1,42 +0,0 @@
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

View File

@ -1,37 +0,0 @@
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