Compare commits

..

No commits in common. "main" and "Release-v20201202" have entirely different histories.

157 changed files with 16368 additions and 3985 deletions

12
.github/FUNDING.yml vendored
View File

@ -1,12 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: domenicogentner
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

16
.gitignore vendored
View File

@ -1,14 +1,8 @@
.idea
__pycache__/
venv
db/*.db
db/*.db-shm
db/*.db-wal
*.old
*.server
/backup/
labertasche.yaml
/.secret
/credentials.yaml
*.bak
smileys.yaml
db/labertasche.db-shm
db/labertasche.db-wal
output
/output/
*.sql

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "static/css/sass/bulma"]
path = static/css/sass/bulma
url = git@github.com:jgthms/bulma.git

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2077 Domeniko Gentner <code@tuxstash.de>
Copyright (c) 2020 Domeniko Gentner <code@tuxstash.de>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -15,7 +15,7 @@ flask-login = "*"
sqlalchemy = "*"
requests = "*"
py3-validate-email = "*"
validators = "*"
flask-migrate = "*"
[requires]
python_version = "3.8"

150
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "3a5cabc81d97143e23ec48af6c789d70ba16128d02104f8323100443b44e6b10"
"sha256": "bda9276f38dcb49704cadb2f9097ecdfa1dafdd4e4b3d6666dfcb92d24f0ea57"
},
"pipfile-spec": 6,
"requires": {
@ -16,6 +16,13 @@
]
},
"default": {
"alembic": {
"hashes": [
"sha256:4e02ed2aa796bd179965041afa092c55b51fb077de19d61835673cc80672c01c",
"sha256:5334f32314fb2a56d86b4c4dd1ae34b08c03cae4cb888bc699942104d66bc245"
],
"version": "==1.4.3"
},
"antispam": {
"hashes": [
"sha256:e188b424ea9b76c408a592a5ff60eb1280f45f26b404db4d5e96123f485de39b"
@ -25,17 +32,17 @@
},
"certifi": {
"hashes": [
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
"sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd",
"sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"
],
"version": "==2020.12.5"
"version": "==2020.11.8"
},
"chardet": {
"hashes": [
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==4.0.0"
"version": "==3.0.4"
},
"click": {
"hashes": [
@ -44,13 +51,6 @@
],
"version": "==7.1.2"
},
"decorator": {
"hashes": [
"sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760",
"sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"
],
"version": "==4.4.2"
},
"dnspython": {
"hashes": [
"sha256:044af09374469c3a39eeea1a146e8cac27daec951f1f1f157b1962fc7cb9d1b7",
@ -89,6 +89,14 @@
"index": "pypi",
"version": "==0.5.0"
},
"flask-migrate": {
"hashes": [
"sha256:4dc4a5cce8cbbb06b8dc963fd86cf8136bd7d875aabe2d840302ea739b243732",
"sha256:a69d508c2e09d289f6e55a417b3b8c7bfe70e640f53d2d9deb0d056a384f37ee"
],
"index": "pypi",
"version": "==2.5.3"
},
"flask-sqlalchemy": {
"hashes": [
"sha256:05b31d2034dd3f2a685cbbae4cfc4ed906b2a733cff7964ada450fd5e462b84e",
@ -118,6 +126,13 @@
],
"version": "==2.11.2"
},
"mako": {
"hashes": [
"sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27",
"sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9"
],
"version": "==1.1.3"
},
"markupsafe": {
"hashes": [
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
@ -163,6 +178,21 @@
"index": "pypi",
"version": "==0.2.12"
},
"python-dateutil": {
"hashes": [
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
],
"version": "==2.8.1"
},
"python-editor": {
"hashes": [
"sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d",
"sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b",
"sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8"
],
"version": "==1.0.4"
},
"pyyaml": {
"hashes": [
"sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
@ -184,11 +214,11 @@
},
"requests": {
"hashes": [
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
"sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8",
"sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"
],
"index": "pypi",
"version": "==2.25.1"
"version": "==2.25.0"
},
"six": {
"hashes": [
@ -199,47 +229,47 @@
},
"sqlalchemy": {
"hashes": [
"sha256:04f995fcbf54e46cddeb4f75ce9dfc17075d6ae04ac23b2bacb44b3bc6f6bf11",
"sha256:0c6406a78a714a540d980a680b86654feadb81c8d0eecb59f3d6c554a4c69f19",
"sha256:0c72b90988be749e04eff0342dcc98c18a14461eb4b2ad59d611b57b31120f90",
"sha256:108580808803c7732f34798eb4a329d45b04c562ed83ee90f09f6a184a42b766",
"sha256:1418f5e71d6081aa1095a1d6b567a562d2761996710bdce9b6e6ba20a03d0864",
"sha256:17610d573e698bf395afbbff946544fbce7c5f4ee77b5bcb1f821b36345fae7a",
"sha256:216ba5b4299c95ed179b58f298bda885a476b16288ab7243e89f29f6aeced7e0",
"sha256:2ff132a379838b1abf83c065be54cef32b47c987aedd06b82fc76476c85225eb",
"sha256:314f5042c0b047438e19401d5f29757a511cfc2f0c40d28047ca0e4c95eabb5b",
"sha256:318b5b727e00662e5fc4b4cd2bf58a5116d7c1b4dd56ffaa7d68f43458a8d1ed",
"sha256:3ab5b44a07b8c562c6dcb7433c6a6c6e03266d19d64f87b3333eda34e3b9936b",
"sha256:426ece890153ccc52cc5151a1a0ed540a5a7825414139bb4c95a868d8da54a52",
"sha256:491fe48adc07d13e020a8b07ef82eefc227003a046809c121bea81d3dbf1832d",
"sha256:4a84c7c7658dd22a33dab2e2aa2d17c18cb004a42388246f2e87cb4085ef2811",
"sha256:54da615e5b92c339e339fe8536cce99fe823b6ed505d4ea344852aefa1c205fb",
"sha256:5a7f224cdb7233182cec2a45d4c633951268d6a9bcedac37abbf79dd07012aea",
"sha256:61628715931f4962e0cdb2a7c87ff39eea320d2aa96bd471a3c293d146f90394",
"sha256:62285607a5264d1f91590abd874d6a498e229d5840669bd7d9f654cfaa599bd0",
"sha256:62fb881ba51dbacba9af9b779211cf9acff3442d4f2993142015b22b3cd1f92a",
"sha256:68428818cf80c60dc04aa0f38da20ad39b28aba4d4d199f949e7d6e04444ea86",
"sha256:6aaa13ee40c4552d5f3a59f543f0db6e31712cc4009ec7385407be4627259d41",
"sha256:70121f0ae48b25ef3e56e477b88cd0b0af0e1f3a53b5554071aa6a93ef378a03",
"sha256:715b34578cc740b743361f7c3e5f584b04b0f1344f45afc4e87fbac4802eb0a0",
"sha256:758fc8c4d6c0336e617f9f6919f9daea3ab6bb9b07005eda9a1a682e24a6cacc",
"sha256:7d4b8de6bb0bc736161cb0bbd95366b11b3eb24dd6b814a143d8375e75af9990",
"sha256:81d8d099a49f83111cce55ec03cc87eef45eec0d90f9842b4fc674f860b857b0",
"sha256:888d5b4b5aeed0d3449de93ea80173653e939e916cc95fe8527079e50235c1d2",
"sha256:95bde07d19c146d608bccb9b16e144ec8f139bcfe7fd72331858698a71c9b4f5",
"sha256:9bf572e4f5aa23f88dd902f10bb103cb5979022a38eec684bfa6d61851173fec",
"sha256:bab5a1e15b9466a25c96cda19139f3beb3e669794373b9ce28c4cf158c6e841d",
"sha256:bd4b1af45fd322dcd1fb2a9195b4f93f570d1a5902a842e3e6051385fac88f9c",
"sha256:bde677047305fe76c7ee3e4492b545e0018918e44141cc154fe39e124e433991",
"sha256:c389d7cc2b821853fb018c85457da3e7941db64f4387720a329bc7ff06a27963",
"sha256:d055ff750fcab69ca4e57b656d9c6ad33682e9b8d564f2fbe667ab95c63591b0",
"sha256:d53f59744b01f1440a1b0973ed2c3a7de204135c593299ee997828aad5191693",
"sha256:f115150cc4361dd46153302a640c7fa1804ac207f9cc356228248e351a8b4676",
"sha256:f1e88b30da8163215eab643962ae9d9252e47b4ea53404f2c4f10f24e70ddc62",
"sha256:f8191fef303025879e6c3548ecd8a95aafc0728c764ab72ec51a0bdf0c91a341"
"sha256:009e8388d4d551a2107632921320886650b46332f61dc935e70c8bcf37d8e0d6",
"sha256:0157c269701d88f5faf1fa0e4560e4d814f210c01a5b55df3cab95e9346a8bcc",
"sha256:0a92745bb1ebbcb3985ed7bda379b94627f0edbc6c82e9e4bac4fb5647ae609a",
"sha256:0cca1844ba870e81c03633a99aa3dc62256fb96323431a5dec7d4e503c26372d",
"sha256:166917a729b9226decff29416f212c516227c2eb8a9c9f920d69ced24e30109f",
"sha256:1f5f369202912be72fdf9a8f25067a5ece31a2b38507bb869306f173336348da",
"sha256:2909dffe5c9a615b7e6c92d1ac2d31e3026dc436440a4f750f4749d114d88ceb",
"sha256:2b5dafed97f778e9901b79cc01b88d39c605e0545b4541f2551a2fd785adc15b",
"sha256:2e9bd5b23bba8ae8ce4219c9333974ff5e103c857d9ff0e4b73dc4cb244c7d86",
"sha256:3aa6d45e149a16aa1f0c46816397e12313d5e37f22205c26e06975e150ffcf2a",
"sha256:4bdbdb8ca577c6c366d15791747c1de6ab14529115a2eb52774240c412a7b403",
"sha256:53fd857c6c8ffc0aa6a5a3a2619f6a74247e42ec9e46b836a8ffa4abe7aab327",
"sha256:5cdfe54c1e37279dc70d92815464b77cd8ee30725adc9350f06074f91dbfeed2",
"sha256:5d92c18458a4aa27497a986038d5d797b5279268a2de303cd00910658e8d149c",
"sha256:632b32183c0cb0053194a4085c304bc2320e5299f77e3024556fa2aa395c2a8b",
"sha256:7c735c7a6db8ee9554a3935e741cf288f7dcbe8706320251eb38c412e6a4281d",
"sha256:7cd40cb4bc50d9e87b3540b23df6e6b24821ba7e1f305c1492b0806c33dbdbec",
"sha256:84f0ac4a09971536b38cc5d515d6add7926a7e13baa25135a1dbb6afa351a376",
"sha256:8dcbf377529a9af167cbfc5b8acec0fadd7c2357fc282a1494c222d3abfc9629",
"sha256:950f0e17ffba7a7ceb0dd056567bc5ade22a11a75920b0e8298865dc28c0eff6",
"sha256:9e379674728f43a0cd95c423ac0e95262500f9bfd81d33b999daa8ea1756d162",
"sha256:b15002b9788ffe84e42baffc334739d3b68008a973d65fad0a410ca5d0531980",
"sha256:b6f036ecc017ec2e2cc2a40615b41850dc7aaaea6a932628c0afc73ab98ba3fb",
"sha256:bad73f9888d30f9e1d57ac8829f8a12091bdee4949b91db279569774a866a18e",
"sha256:bbc58fca72ce45a64bb02b87f73df58e29848b693869e58bd890b2ddbb42d83b",
"sha256:bca4d367a725694dae3dfdc86cf1d1622b9f414e70bd19651f5ac4fb3aa96d61",
"sha256:be41d5de7a8e241864189b7530ca4aaf56a5204332caa70555c2d96379e18079",
"sha256:bf53d8dddfc3e53a5bda65f7f4aa40fae306843641e3e8e701c18a5609471edf",
"sha256:c092fe282de83d48e64d306b4bce03114859cdbfe19bf8a978a78a0d44ddadb1",
"sha256:c3ab23ee9674336654bf9cac30eb75ac6acb9150dc4b1391bec533a7a4126471",
"sha256:ce64a44c867d128ab8e675f587aae7f61bd2db836a3c4ba522d884cd7c298a77",
"sha256:d05cef4a164b44ffda58200efcb22355350979e000828479971ebca49b82ddb1",
"sha256:d2f25c7f410338d31666d7ddedfa67570900e248b940d186b48461bd4e5569a1",
"sha256:d3b709d64b5cf064972b3763b47139e4a0dc4ae28a36437757f7663f67b99710",
"sha256:e32e3455db14602b6117f0f422f46bc297a3853ae2c322ecd1e2c4c04daf6ed5",
"sha256:ed53209b5f0f383acb49a927179fa51a6e2259878e164273ebc6815f3a752465",
"sha256:f605f348f4e6a2ba00acb3399c71d213b92f27f2383fc4abebf7a37368c12142",
"sha256:fcdb3755a7c355bc29df1b5e6fb8226d5c8b90551d202d69d0076a8a5649d68b"
],
"index": "pypi",
"version": "==1.3.22"
"version": "==1.3.20"
},
"urllib3": {
"hashes": [
@ -248,14 +278,6 @@
],
"version": "==1.26.2"
},
"validators": {
"hashes": [
"sha256:0143dcca8a386498edaf5780cbd5960da1a4c85e0719f3ee5c9b41249c4fefbd",
"sha256:37cd9a9213278538ad09b5b9f9134266e7c226ab1fede1d500e29e0a8fbb9ea6"
],
"index": "pypi",
"version": "==0.18.2"
},
"werkzeug": {
"hashes": [
"sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43",

View File

@ -72,7 +72,7 @@ The config looks like this:
Other options:
* [gunicorn](https://gunicorn.org/) + Apache/Nginx with Proxy Pass
* [gunicorn](https://gunicorn.org/https://gunicorn.org/) + Apache/Nginx with Proxy Pass
Once you can see the administrative page, you can start integrating it into Hugo.
@ -101,12 +101,12 @@ Within that template the following structure is needed:
{{ if (fileExists $location ) }}
{{ $dataJ := getJSON $location }}
{{ range $dataJ.comments }}
{# HTML and template codes here #}
{# This is to display replies to this comment, you can use them same variables #}
{{ range where $dataJ.replies "replied_to" .comment_id }}
{# HTML and template codes here for replies #}
{{end}}
{# HTML and template codes here #}
{{ end }}
{# This is to display replies to this comment, you can use them same variables #}
{{ range where $dataJ.replies "replied_to" .comment_id }}
{# HTML and template codes here for replies #}
{{end}}
{{ end }}
```

View File

@ -1,21 +0,0 @@
# How to use this example
Please run
`hugo --bind dev.localhost --baseURL http://dev.localhost --disableLiveReload`
in this folder to view this example. Point your web browser then to
[dev.localhost](http://dev.localhost:1313).
Please also add
`127.0.0.1 dev.localhost`
to
* Windows: `%WINDIR%\system32\drivers\etc\hosts`
* MAC/Linux: `/etc/hosts`
before running the example.
**There is another readme when you view the page.**

View File

@ -1,6 +0,0 @@
---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
---

View File

@ -1,4 +0,0 @@
baseURL = "http://dev.localhost/"
languageCode = "en-us"
disableKinds = ["taxonomy", "term"]
ignoreErrors = ["error-disable-taxonomy"]

View File

@ -1,94 +0,0 @@
---
title: "Labertasche minimal implementation example"
date: 2020-12-03 09:00:00
categories: blog
---
This is a minimal example on how to implement Labertasche,
using Bulma CSS. The CSS is not that important, however, it
also shows how to utilize a modal dialogue to give your users
a good experience.
<!--more-->
## Setup
Please modify `mail_credentials.yaml` and make sure mail can be sent.
Everything else is set up. You can run flask with pycharm or on a local
server. It is up to you. I recommend using pycharm with the flask parameters
`--host=dev.localhost --port=1314`. Make sure `dev.localhost` is in your
hosts file and resolves to `127.0.0.1`. This is necessary to set a cookie domain.
The server will not be able to run without.
## Where to start?
Start by reading `layouts/_default/baseof.html`. Notice the Javascript.
It has the default `labertasche.js` included and a custom file, where I
handle the callbacks. In production, you would concat these files using
the Hugo asset pipeline. I've left them separate, so you can see what is custom and what is included.
The next stop should be `single.html`. There you can find the first go block
needed, which adds the comments to each article in Hugo. Query for sections
if you want to exclude certain sections or only allow one, e.g. `blog`.
Last but not least, `comments.html` in the partials folder. This is where
basically all the magic happens. Read the javascript functions as they appear.
Basically, all I am doing is to query the DOM elements and adding/removing
classes as I go, to display certain things. There is also a quick explanation further down.
**Please note**: This version has a modified reply function, so it displays the
hidden field with the reply id.
This does not occur on the production version, but can be helpful for debugging.
## Javascript functions explained
This is a quick and short explanation of all javascript functions. Yes, you may use and modify them.
### labertasche_text_counter()
This function counts the amount of characters put into the text area. This is purely cosmetic and only the first
filter. If users have disabled Javascript, they could circumvent this, so the server checks lengths too.
### labertasche_validate_mail()
This checks if the entered text is a valid mail address, with a regex match. This does not check if the
domain exists or if the mail is _really_ an email, but that is done server side. It's only used to minimize false
requests.
### labertasche_modal_hide()
This hides the modal dialog when the button on the modal is clicked.
### labertasche_comment_not_found()
When a comment is not valid, Labertasche will redirect to `dev.localhost?cnf=true`. This function shows a modal
to inform the user about it. The JS for checking this parameter is in `baseof.html`.
### labertasche_comment_deleted()
Same as above, but with `dev.localhost?deleted=true`. This happens when a user deletes the comment via the link
sent by mail.
### labertasche_post_callback(state)
This is the callback used via the Labertasche post function. It simply displays different modals when certain error
codes are received. This is extremely useful, because you can inform your user about what is happening.
### labertasche_reply_callback(state, comment_id)
The callback for the reply callback. This does a little more, it displays a new button which the user can press to
disable the reply and go to a parent comment. This is useful, because the user does not have to reload the site and
therefore, does not need to type it all again, if the reply was done in error.
## Feedback
Hope this example makes it more comfortable to use Labertasche, please send me a mail or open an issue if anything
is unclear.
## Try it out!
Scroll down and comment. This is only locally. Please note: If livereload is enabled, you may not see all dialogs.
Turn livereload in Hugo off, if you want to see all of them:
`--disableLiveReload`.
The example comments also will disappear when you comment, as they are not included in the database.

View File

@ -1,61 +0,0 @@
---
title: "Stramine ad coniugiale hi Procne"
date: 2020-12-04 08:00:00
categories: blog
---
## Qui velox repperit
Lorem markdownum spatio animas animorum Scyrumve Noctis gramine, fata, sit
cives, cui mea. Abesto Thesea coniecit, in rictus *quem pedis caret* tutaeque
sacra.
1. Urit deae freto nubifer oculi
2. Ferrumque dilata quaeque
3. Mihi luminis color tandem mirum quodque
<!--more-->
## Videt accipiunt habet
Potest rapto: nata honores, primos, laudamus scrutantur in. Similis incursurus
enim inritata postes, est caelo, sis *nondum*, spumantiaque licet tenens
conbibitur excutis levis. Spargit dedere laetissimus liquidi, ad mergit, lintea
*armis erunt esse* aratri, sideraque piceis.
Gestare petentes saevo multoque, ad *esset inhibere* omnibus, iter de Dixerat
dira. Illi mora sed altera ferrum tibi, qui ignis aris nocti quatiens est.
## Amplexus stantes paciscor tot unum
Amens fugit membra flabat gemellam et Venus **protinus**. Gyaros esse tibi exhausta. Nulla sed
numina linguae plura, prosiluit tamen, inscius, cui Phoebus circumspexit
spatiumque **indigenae caecaque**.
if (server + san < w(ospf, webMemory, speed_column +
sli_vaporware_definition)) {
kernelBarFile = archieSmishing;
base_png_click(rte_warm, dongle);
}
var midi = addressP.router(reader.koffice(dslClickKeylogger(rpm, 2,
dataRom), 5));
cd_media = koffice.dos.shortcut(3, html_boot.horizontal_trash_extension(
subdirectory)) - tunneling(login, camelcase_cursor_opacity(
flash_graphic, soap, serp_e_debug));
sdk_lte_software(5);
## Attollit unde fingens
Longeque frangunt, spectant temptavit, reperta invito, tectis face vos mirabile
Cycladas. Reliquit voverat, quattuor imago utinam crudelem rapta, nomina ullos
latuit resurgere. Terraque vitae.
1. Senex et ipse esse cruentior caluere
2. Sub quae
3. Ubi sunt sedens cladis certamine maior hiscere
Aequantibus admota; cuncta sit quod fugias dextra certaminis oro ecce auditis
pater. Fluunt herbas si est. Animam precesque esse gradumque videndo vultum,
lapides, fera **corpora temperat**, adnuit fortis. Se et Ceycis; ille tergo
frondes hospitibus quoque et? Dixit inposuit in cetera pinus triplices convicia;
rupit intus suorum, et?

View File

@ -1 +0,0 @@
{"comments": [{"comment_id": 9, "email": "commenter9@", "content": "9 This is a test comment and has no actual value. Please test all methods on this.", "created_on": "2020-12-16 23:37:00", "replied_to": null, "gravatar": "d9eef4df0ae5bfc1a9a9b1e39a99c07f"}], "replies": [{"comment_id": 10, "email": "commenter10@", "content": "10 This is a reply to the previous comment and has no actual value. Please test all methods on this.", "created_on": "2020-12-16 23:37:00", "replied_to": 9, "gravatar": "d9eef4df0ae5bfc1a9a9b1e39a99c07f"}]}

View File

@ -1,22 +0,0 @@
{
"comments": [
{
"comment_id": 1,
"email": "commenter1@",
"content": "This is an example comment with over 40 characters.",
"created_on": "2020-12-04 12:23:14",
"replied_to": null,
"gravatar": "d9eef4df0ae5bfc1a9a9b1e39a99c07f"
}
],
"replies": [
{
"comment_id": 2,
"email": "commenter2@",
"content": "This is an example reply, to test if this works.",
"created_on": "2020-12-04 12:24:19",
"replied_to": 1,
"gravatar": "d9eef4df0ae5bfc1a9a9b1e39a99c07f"
}
]
}

View File

@ -1 +0,0 @@
{"comments": [], "replies": [{"comment_id": 2, "email": "commenter2@", "content": "2 This is a reply to the previous comment and has no actual value. Please test all methods on this.", "created_on": "2020-12-16 23:37:00", "replied_to": 1, "gravatar": "d9eef4df0ae5bfc1a9a9b1e39a99c07f"}]}

View File

@ -1,56 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<link rel="stylesheet" href="/css/labertasche.css" media="screen">
<title>Labertasche Example</title>
</head>
<body class="is-family-sans-serif bg-darkslate">
<section>
<nav class="navbar" role="navigation" aria-label="main navigation">
<a class="navbar-item is-size-4" href="/">
&nbsp;Labertasche Example
</a>
<div class="navbar-start"></div>
<div class="navbar-end"></div>
</nav>
<div class="p-4">
{{ block "main" . }}
{{ end }}
</div>
</section>
<script defer src="/js/labertasche.js"></script>
<script defer src="/js/mysite.js"></script>
<script defer>
document.addEventListener('DOMContentLoaded', () => {
// Comments
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("cnf") === "true"){
labertasche_comment_not_found();
}
if (urlParams.get("deleted") === "true"){
labertasche_comment_deleted();
}
});
</script>
<!-- Modal for notifications -->
<div class="modal" id="labertasche-modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="content p-5 mb-0 is-rounded bg-yayellow has-text-black">
<p class="title">Labertasche</p>
</div>
<div class="content p-5 mb-0 bg-deepmatte has-text-white" id="labertasche-modal-text">
</div>
<div class="content p-5 bg-deepmatte has-text-white border-top">
<button onclick="labertasche_modal_hide();" class="button is-warning" aria-label="close">
OK
</button>
</div>
</div>
<button onclick="labertasche_modal_hide();" class="modal-close is-large" aria-label="close"></button>
</div>
</body>
</html>

View File

@ -1,7 +0,0 @@
{{ block "frontpage_article" . }}
<article class="p-3 bg-deepmatte brdr-yayellow">
<p class="title has-text-white">{{ .Title }}</p>
<p>{{ .Summary }}</p>
<p><a href="{{.RelPermalink}}">Read the whole article...</a></p>
</article>
{{ end }}

View File

@ -1,12 +0,0 @@
{{ define "main" }}
<div class="container">
<div class="content">
{{ $last_article := (.Site.GetPage "blog" .Section).Pages.ByPublishDate }}
{{ range last 2 $last_article }}
<div class="mt-4">
{{ .Render "frontpage_article" }}
</div>
{{ end }}
</div>
</div>
{{ end }}

View File

@ -1,13 +0,0 @@
{{ define "main" }}
<article class="container p-3 bg-deepmatte brdr-yayellow">
<p class="title has-text-white">{{ .Title }}</p>
<div class="content has-text-justified has-text-white">{{ .Content }}</div>
</article>
<article class="container p-3 bg-deepmatte brdr-yayellow mt-5">
<div>
{{ $file := replaceRE "^(.*)[\\/]$" "data$1.json" .Page.RelPermalink }}
{{ .Scratch.Set "location" $file }}
{{ partial "partials/comments" . }}
</div>
</article>
{{ end }}

View File

@ -1,111 +0,0 @@
{{ $location := .Scratch.Get "location" }}
<!--suppress XmlDuplicatedId -->
<h1 class="is-uppercase has-text-white">comments</h1>
<div class="mb-5" id="labertasche-comment-section" data-remote="http://dev.localhost:1314/comments/default/new">
<div class="control is-expanded">
<input onkeypress="labertasche_validate_mail();"
onfocusout="labertasche_validate_mail();"
maxlength="100"
id="labertasche-mail"
class="input"
type="email"
placeholder="joedoe@example.com">
<label for="labertasche-mail"></label>
</div>
<div class="control is-expanded mt-3">
<textarea oninput="labertasche_text_counter();"
id="labertasche-text"
class="textarea"
rows="5"
maxlength="1000"
placeholder="40 minimum characters, type something nice..."></textarea>
<label for="labertasche-text"></label>
<p id="labertasche-text-helper"
class="help is-danger">Characters: <span id="labertasche-counter">0/1000</span></p>
</div>
<div class="control mt-3">
<button onclick="labertasche_post_comment(this, labertasche_post_callback);"
class="button is-warning px-6 mr-4 is-medium"
id="labertasche-comment-button">
<span>Comment</span>
</button>
</div>
</div>
<article>
<div class="media mb-5 brdr-yayellow my-shadow-subtle bg-compliment">
<figure class="media-left ml-0 mb-0">
<p class="image is-128x128">
<img alt="gravatar portrait" src="/images/default.jpg">
</p>
</figure>
<div class="media-content">
<div class="content mr-5 mt-2 has-text-left">
Pinned by <span class="fg-yellow">admin@example.com</span>
<br><br>
<span class="mt-5 has-text-justified">
<span>
Come join the discussion and write something nice. You will have to confirm your comment by mail,
so make sure it is legit and not a throwaway. Only the name part of it will be displayed, so
don't worry about spam.
</span>
</span>
</div>
</div>
</div>
</article>
{{ if (fileExists $location ) }}
{{ $dataJ := getJSON $location }}
{{ range $dataJ.comments }}
<article>
<div class="media mb-5 brdr-yayellow my-shadow-subtle bg-compliment">
<figure class="media-left ml-0 mb-0">
<p class="image is-128x128">
<img alt="gravatar portrait" src="https://www.gravatar.com/avatar/{{.gravatar}}.jpg">
</p>
</figure>
<div class="media-content">
<div class="content mr-5 mt-2">
<a id="comment_{{.comment_id}}" href="#comment_{{.comment_id}}">#{{.comment_id}}</a>
Posted by <span class="fg-yellow">{{.email}}</span> <small>on {{.created_on}}</small>
<br><br>
<span class="mt-5">
{{.content}}
</span>
</div>
<div class="is-fullwidth bg-yayellow has-text-centered">
<!--suppress JSUnresolvedVariable -->
<a class="has-text-black" href="#labertasche-comment-section"
onclick="labertasche_reply_to({{.comment_id}}, labertasche_reply_callback);">
reply
</a>
</div>
</div>
</div>
</article>
{{ range where $dataJ.replies "replied_to" .comment_id }}
<article>
<div class="media margin-left-128 mb-5 brdr-yayellow my-shadow-subtle bg-compliment">
<figure class="media-left ml-0 mb-0">
<p class="image is-128x128">
<img alt="gravatar portrait" src="https://www.gravatar.com/avatar/{{.gravatar}}.jpg">
</p>
</figure>
<div class="media-content">
<div class="content mr-5 mt-2">
<a id="comment_{{.comment_id}}" href="#comment_{{.comment_id}}">#{{.comment_id}}</a>
Posted by <span class="fg-yellow">{{.email}}</span> <small>on {{.created_on}}&nbsp;
</small>
<br><br>
<span class="mt-5">
{{.content}}
</span>
</div>
</div>
</div>
</article>
{{end}}
{{ end }}
{{ end }}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -1,122 +0,0 @@
//**********************************************************************************
// * _author : Domeniko Gentner
// * _mail : code@tuxstash.de
// * _repo : https://git.tuxstash.de/gothseidank/labertasche
// * _license : This project is under MIT License
// *********************************************************************************/
/*
//Callback example for post. Possible messages:
// post-min-length
// post-max-length
// post-invalid-json
// post-duplicate
// post-internal-server-error
// post-success
// post-before-fetch
function labertasche_callback(state)
{
if (state === "post-before-fetch"){
}
if (state === "post-min-length"){
}
if (state === "post-success"){
}
if (state === "post-fetch-exception" || state === "post-internal-server-error"){
}
if (state === "post-invalid-email"){
}
}
// Callback for initiating and cancelling replies.
// Posstible message: 'on' and 'off'
function labertasche_reply_callback()
{
if (state === "on"){
}
if (state === "off"){
}
}
*/
function labertasche_reply_to(comment_id, callback)
{
let comments = document.getElementById('labertasche-comment-section');
if (comments){
if (document.getElementById('labertasche-replied-to')){
document.getElementById('labertasche-replied-to').remove();
callback('off', comment_id);
if (comment_id === -1){
return false;
}
}
let reply = document.createElement("input");
reply.setAttribute("type", "text");
reply.setAttribute("id", "labertasche-replied-to");
//reply.classList.add("is-hidden");
reply.value = comment_id;
comments.appendChild(reply);
callback('on', comment_id);
}
else{
console.log("Missing text input with id labertasche-comment-section");
}
}
function labertasche_post_comment(btn, callback)
{
let remote = document.getElementById('labertasche-comment-section').dataset.remote;
let comment = document.getElementById('labertasche-text').value.trim();
let mail = document.getElementById('labertasche-mail').value.trim();
let reply = document.getElementById('labertasche-replied-to');
if (mail.length <= 0 || comment.length < 40){
callback('post-min-length');
if(btn) {
return false;
}
return false;
}
let reply_value = null
if (reply != null){
reply_value = reply.value;
}
callback('post-before-fetch');
fetch(remote,
{
mode:"cors",
headers: {
'Access-Control-Allow-Origin':'*',
'Accept': 'application/json',
'Content-Type': 'application/json'
},
method: "POST",
// use real location
body: JSON.stringify({ "email": mail,
"content": comment,
"location": window.location.pathname,
"replied_to": reply_value
})
})
.then(async function(response){
let result = await response.json();
callback(result['status']);
})
.catch(function(exc){
console.log(exc);
callback('post-fetch-exception');
})
// Don't reload the page
return false;
}

View File

@ -1,156 +0,0 @@
function labertasche_text_counter()
{
let txt = document.getElementById('labertasche-text');
let cntr = document.getElementById('labertasche-counter');
let maxlen = txt.getAttribute("maxlength");
let helper = document.getElementById("labertasche-text-helper");
if (cntr && txt){
cntr.innerText = txt.value.length + "/" + maxlen;
if (txt.value.length > 40){
if (helper.classList.contains('is-danger')){
helper.classList.remove("is-danger");
helper.classList.add("is-success");
txt.classList.add('is-success');
txt.classList.remove('is-danger');
}
}
if (txt.value.length < 40){
if (helper.classList.contains('is-success')){
helper.classList.remove("is-success");
helper.classList.add("is-danger");
txt.classList.add('is-danger');
txt.classList.remove('is-success');
}
}
}
}
function labertasche_validate_mail()
{
let email = document.getElementById("labertasche-mail");
let is_valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value);
if (is_valid){
email.classList.remove("is-danger")
email.classList.add("is-success")
}
else{
email.classList.add("is-danger")
email.classList.remove("is-success")
}
}
function labertasche_modal_hide(url=null)
{
let modal = document.getElementById('labertasche-modal');
if (modal != null){
if (modal.classList.contains("is-active")){
modal.classList.remove('is-active');
}
}
if (!modal.dataset.url) {
window.location.reload(true);
}
else{
window.location = modal.dataset.url;
}
}
function labertasche_comment_not_found()
{
let modal = document.getElementById('labertasche-modal');
let modal_text = document.getElementById('labertasche-modal-text');
modal_text.innerText = "The link you followed was not valid. It either doesn't exist or was already used.";
modal.setAttribute('data-url', window.location.protocol + "//" + window.location.host)
modal.classList.add('is-active');
}
function labertasche_comment_deleted()
{
let modal = document.getElementById('labertasche-modal');
let modal_text = document.getElementById('labertasche-modal-text');
modal_text.innerText = "Your comment has been deleted. Thank you for being here.";
modal.setAttribute('data-url', window.location.protocol + "//" + window.location.host)
modal.classList.add('is-active');
}
/*
post-min-length
post-max-length
post-invalid-json
post-duplicate
post-internal-server-error
post-success
post-before-fetch
*/
function labertasche_post_callback(state)
{
// Elements
let modal = document.getElementById('labertasche-modal');
let modal_text = document.getElementById('labertasche-modal-text');
let button = document.getElementById('labertasche-comment-button');
if (state === "post-before-fetch"){
button.classList.add("is-loading");
}
if (state === "post-min-length"){
button.classList.remove("is-loading");
modal_text.innerText = "Your comment was not entered because it is too short. Please write at least 40 characters."
modal.classList.add('is-active');
}
if (state === "post-success"){
button.classList.remove("is-loading");
if (state['sendotp']) {
modal_text.innerText = "Your comment was entered, but you need to confirm it, before it becomes active. Please check your mail!"
}
else{
modal_text.innerText = "Your comment was successfully entered."
}
modal.classList.add('is-active');
}
if (state === "post-fetch-exception" || state === "post-internal-server-error"){
button.classList.remove("is-loading");
modal_text.innerText = "Your comment was not entered because there was an error, which was recorded and reported automatically.";
modal.classList.add('is-active');
}
if (state === "post-duplicate"){
button.classList.remove("is-loading");
modal_text.innerText = "This comment was already made.";
modal.classList.add('is-active');
}
if (state === "post-invalid-email"){
button.classList.remove("is-loading");
modal_text.innerText = "The email you have entered appears to be invalid. Please contact me if you think this was in error.";
modal.classList.add('is-active');
}
}
function labertasche_reply_callback(state, comment_id)
{
if (state === "on"){
let comment_btn = document.getElementById('labertasche-comment-button');
let parent = comment_btn.parentElement
let new_btn = document.createElement("button");
new_btn.classList.add("button");
new_btn.classList.add("is-danger");
new_btn.classList.add("is-medium");
new_btn.classList.add("px-6");
new_btn.setAttribute("id", "labertasche-cancel-reply");
new_btn.onclick = function() { labertasche_reply_to(-1, labertasche_reply_callback); }
new_btn.innerHTML = '<span>Cancel Reply</span>';
parent.appendChild(new_btn);
comment_btn.innerHTML = "<span class='is-medium'>Reply to #" + comment_id + "</span>";
}
if (state === "off"){
console.log("off");
let comment_btn = document.getElementById('labertasche-comment-button');
comment_btn.innerHTML = "<span class='is-medium'>Comment</span>";
let cancel = document.getElementById('labertasche-cancel-reply');
if (cancel){
cancel.remove();
}
}
}

View File

@ -1,48 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from platform import system
from labertasche.settings import LegacySettings
from os import access, W_OK
from sys import exit
print("""
This will convert the current config file to the new system.
This will create new files in /etc/labertasche:
- .secret: The current secret of this app
- credentials.yaml: This file will contain your selected username and password
- labertasche.yaml: This file will contain the basic configuration
- smileys.yaml: This will contain all your smileys.
""")
base_path = '.'
if system().lower() == 'linux':
base_path = '/etc/labertasche/'
if not access(base_path, W_OK):
print(f"I do not have write access to this path: {base_path}. Please correct that and run the script again.")
exit(1)
# noinspection PyBroadException
try:
legacy = LegacySettings()
legacy.convert_to_v2()
except Exception as e:
print("""
Something went wrong. Your config is still available as labertasche.bak.
Consider reporting this as a bug on github please. The message was:\n
""")
print(str(e))
exit(1)
print("""
The upgrade is now complete. Your previous settings file has been stored as labertasche.bak.
LEAVE THIS FILE AS IS UNTIL AFTER THE DATABASE UPGRADE!
Please start the flask app and follow the database upgrade instructions.
""")

View File

@ -1,108 +0,0 @@
/**********************************************************************************
* _author : Domeniko Gentner
* _mail : code@tuxstash.de
* _repo : https://git.tuxstash.de/gothseidank/labertasche
* _license : This project is under MIT License
**********************************************************************************
*
* This script generates sample data for the example implementation.
* Feed it into the automatically created database with either DBBeaver
* or the sqlite command line tool.
*
* Please note: Labertasche must have run once to create the database!
*
**********************************************************************************
*/
/* delete old data */
DELETE FROM t_comments;
DELETE FROM t_projects;
DELETE FROM t_email;
DELETE FROM t_comments;
DELETE FROM t_location;
/* Create example projects */
INSERT INTO t_projects (id_project, name)
VALUES
(1, 'default'),
(2, 'example.com'),
(3, 'tuxstash.de'),
(4, 'beispiel.de'),
(5, 'labertasche.tuxstash.de')
;
/* Create existing locations for each project */
INSERT INTO t_location (id_location, location, project_id)
VALUES
(1, '/blog/stramine/', 1),
(2, '/blog/readme/', 1),
(3, '/blog/article-1/', 2),
(4, '/blog/article-2/', 2),
(5, '/blog/article-3/', 3),
(6, '/blog/article-4/', 3),
(7, '/blog/article-5/', 4),
(8, '/blog/article-6/', 4),
(9, '/blog/article-7/', 5),
(10, '/blog/article-8/', 5)
;
/* Create some emails that are blocked and allowed */
INSERT INTO t_email (id_email, email, is_allowed, is_blocked, project_id)
VALUES
(1, "commenter1@example.com", true, false, 1),
(2, "commenter2@example.com", false, true, 1),
(3, "commenter3@example.com", true, false, 2),
(4, "commenter4@example.com", false, true, 2),
(5, "commenter5@example.com", true, false, 3),
(6, "commenter6@example.com", false, true, 3),
(7, "commenter7@example.com", true, false, 4),
(8, "commenter8@example.com", false, true, 4),
(9, "commenter9@example.com", true, false, 5),
(10, "commenter10@example.com", false, true, 5)
;
/* Create some comments */
INSERT INTO t_comments (comments_id, location_id, email, content, created_on, is_published, is_spam, spam_score, replied_to, confirmation, deletion, gravatar, project_id)
VALUES
(1, 1, 'commenter1@example.com', '1 This is a test comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, NULL, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 1),
(2, 1, 'commenter2@example.com', '2 This is a reply to the previous comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, 1, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 1),
(3, 2, 'commenter3@example.com', '3 This is a test comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, NULL, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 1),
(4, 2, 'commenter4@example.com', '4 This is a reply to the previous comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, 3, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 1),
(5, 3, 'commenter5@example.com', '5 This is a test comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, NULL, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 2),
(6, 3, 'commenter6@example.com', '6 This is a reply to the previous comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, 5, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 2),
(7, 4, 'commenter7@example.com', '7 This is a test comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, NULL, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 2),
(8, 4, 'commenter8@example.com', '8 This is a reply to the previous comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, 7, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 2),
(9, 5, 'commenter9@example.com', '9 This is a test comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', false, true, 0.09, NULL, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 3),
(10, 5, 'commenter10@example.com', '10 This is a reply to the previous comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, 9, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 3),
(11, 6, 'commenter11@example.com', '11 This is a test comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.09, NULL, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 3),
(12, 6, 'commenter12@example.com', '12 This is a reply to the previous comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, 11, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 3),
(13, 7, 'commenter13@example.com', '13 This is a test comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, NULL, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 4),
(14, 7, 'commenter14@example.com', '14 This is a reply to the previous comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, 13, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 4),
(15, 8, 'commenter15@example.com', '15 This is a test comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, NULL, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 4),
(16, 8, 'commenter16@example.com', '16 This is a reply to the previous comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, 16, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 4),
(17, 9, 'commenter17@example.com', '17 This is a test comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, NULL, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 5),
(18, 9, 'commenter18@example.com', '18 This is a reply to the previous comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, 18, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 5),
(19, 10, 'commenter19@example.com', '19 This is a test comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, NULL, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 5),
(20, 10, 'commenter20@example.com', '20 This is a reply to the previous comment and has no actual value. Please test all methods on this.', '2020-12-16 23:37:00.000000', true, false, 0.99, 19, NULL, NULL, 'd9eef4df0ae5bfc1a9a9b1e39a99c07f', 5)
;

View File

@ -1,82 +0,0 @@
{
"html_language": "de",
"browser_language": "de-DE",
"ok": "ok",
"link": "link",
"cancel": "abbrechen",
"dashboard": "dashboard",
"username": "benutzername",
"password": "passwort",
"login": "login",
"logout": "abmelden",
"error": "fehler",
"warning": "warnung",
"comments": "kommentare",
"unpublished": "unveröffentlicht",
"published": "veröffentlicht",
"spam": "spam",
"edit": "editieren",
"manage": "verwalten",
"export": "export",
"delete": "löschen",
"new": "neu",
"project": "projekt",
"new_project": "Neues Projekt",
"project_name": "Projekt Name",
"statistics": "statistiken",
"address": "adresse",
"status": "status",
"manage_mail": "Mail Addressen verwalten",
"manage_comments": "Kommentare verwalten",
"manage_comments_delete_comment": "Diesen Kommentar löschen",
"manage_comments_delete_and_block": "Diesen Kommentar löschen und EMail Adresse blocken.",
"manage_comments_allow_comment": "Diesen Kommentar erlauben, aber Email Adresse nicht freischalten.",
"manage_comments_allow_and_approve": "Diesen Kommentar erlauben und Email Adresse freischalten.",
"manage_spam": "Spam verwalten",
"select_article": "Artikel auswählen",
"hooray_no_spam": "Hurra, kein Spam!",
"spam_score": "Spamerkennung",
"tooltip_spam_score": "Je höher der Spam Wert ist, desto höher die Chance, dass es sich um Spam handelt.",
"stats_label_regular_comments": "reguläre Kommentare",
"stats_label_unpublished_comments": "unveröffentlichte Kommentare",
"stats_last_7_days": "Aktivität der letzten 7 Tage",
"stats_total_percentage": "Verhältnis von Kommentaren zu Spam",
"select_project_to_manage": "Projekte:",
"tooltip_create_new_project": "Neues Projekt erzeugen",
"tooltip_delete_project": "Projekt und dazugehörige Daten löschen",
"tooltip_edit_project": "Einstellungen und Daten des Projekts ändern",
"tooltip_export_all_comments": "Alle Kommentare nach Hugo exportieren.<br>Wird normalerweise nicht benötigt.",
"tooltip_manage_this_project": "Dieses Projekt verwalten",
"placeholder_search_mail": "Mail Adressen durchsuchen",
"export_all_comments": "Alle Kommentare exportieren",
"export_warning_text": "Dies wird alle Kommentare neu exportieren. Normalerweise wird das nicht benötigt, kann aber in gewissen Situatonen hilfreich sein.",
"delete_project_warning": "Du bist dabei ein Projekt zu löschen. Dies wird alle dazugehörigen Daten auch löschen. Bitte führe einen manuellen SQL Dump durch, falls du die Daten behalten willst.",
"wish_to_proceed": "Möchtest du fortfahren?",
"tooltip_email_blocked": "Email ist momentan gesperrt. Zum entsperren klicken.",
"tooltip_email_allowed": "Email darf momentan ohne Bestätigung posten. Zum Sperren klicken.",
"tooltip_delete_email": "Eintrag löschen. Email unterliegt wieder den normalen Regeln.",
"javascript_required_field_empty": "Ein Pflichtfeld wurde leer gelassen!",
"javascript_invalid_project_name": "Der Projektname ist nicht gültig, bitte nur Buchstaben und Zahlen verwenden!",
"javascript_project_duplicate": "Ein Projekt mit diesem name existiert bereits!",
"javascript_blogurl_invalid": "Die blog-url ist ungültig!",
"javascript_output_nonexistent": "Der Ausgabe Pfad existiert nicht!",
"javascript_gravatar_cache_nonexistent": "Der caching Pfad existiert nicht!",
"javascript_exception": "Es gab eine unerwartet Ausnahme. Bitte eröffne ein issue auf Github!",
"javascript_edit_project_modal_title": "Projekt %name% editieren",
"description_hugo_url": "Die URL der Hugo Seite für dieses Projekt.",
"tooltip_hugo_url": "Die URL sollte so aussehen: https://beispiel.de",
"tooltip_project_name": "Bitte wähle einen einzigartigen Namen für das Projekt.",
"description_output_path": "Hugo Datenverzeichnis",
"tooltip_output_path": "Der Pfad zum Datenverzeichnis der Hugo Installation. Kann relativ sein.",
"description_gravatar_cache": "Gravatar Bilder lokal speichern?",
"tooltip_gravatar_cache": "Wenn dies aktiviert ist, wird Labertasche Gravatare herunterladen und hier speichern.",
"tooltip_gravatar_dir": "Der Pfad zum Zwischenspeicher für Gravatare. Kann relativ sein.",
"description_gravatar_dir": "Lokales Verzeichnis für Gravatar",
"tooltip_gravatar_size": "Die Größe der Gravatare. Sollte ein vielfaches von 2 sein, bswp. 64, 128 oder 256.",
"description_gravatar_size": "Gravatar Bildgröße",
"description_send_otp": "OTP zur Veröffentlichung senden?",
"tooltip_send_otp": "Wenn aktiviert, bekommt der User ein Einmalpasswort zugesendet, mit dem der Kommentar veröffentlich wird (empfohlen). Wenn deaktiviert, wird der Kommentar immer veröffentlicht (ausser Spam).",
"description_enable_smileys": "Smiley Addon aktivieren?",
"tooltip_enable_smileys": "Wenn aktiviert, werden simple Text Smileys mit Emojis ersetzt. In /etc/labertasche/smileys.yaml findest du die aktivierten Smileys.",
"message_project_404": "Das angegebene Projekt wurde nicht gefunden. Wurde es vielleicht gelöscht? Wenn du denkst, dass dies ein Bug ist, melde es bitte auf Github!"
}

View File

@ -1,82 +0,0 @@
{
"html_language": "en",
"browser_language": "en-US",
"ok": "ok",
"cancel": "cancel",
"link": "link",
"dashboard": "dashboard",
"username": "username",
"password": "password",
"login": "login",
"logout": "logout",
"error": "error",
"warning": "warning",
"comments": "comments",
"unpublished": "unpublished",
"published": "published",
"spam": "spam",
"edit": "edit",
"manage": "manage",
"export": "export",
"delete": "delete",
"new": "new",
"project": "project",
"new_project": "new project",
"statistics": "statistics",
"address": "address",
"status": "status",
"project_name": "Projekt Name",
"manage_mail": "manage mail addresses",
"stats_label_regular_comments": "regular comments",
"stats_label_unpublished_comments": "unpublished comments",
"stats_last_7_days": "Activity last 7 days",
"stats_total_percentage": "Total comment - spam ratio",
"manage_comments": "manage comments",
"manage_comments_delete_comment": "Delete this comment",
"manage_comments_delete_and_block": "Delete this comment and block mail address",
"manage_comments_allow_comment": "Approve this comment, don't approve mail",
"manage_comments_allow_and_approve": "Approve this comment and approve mail",
"select_article": "Select article",
"select_project_to_manage": "Projects",
"hooray_no_spam": "Hooray, no Spam!",
"spam_score": "score",
"tooltip_spam_score": "The higher the spam score is, the more likely it is spam",
"tooltip_create_new_project": "Create a new project",
"tooltip_delete_project": "Delete the project and all of its content",
"tooltip_edit_project": "Edit the name of the project and it's properties",
"tooltip_export_all_comments": "Export all comments to Hugo.<br>This is normally not needed.",
"tooltip_manage_this_project": "Manage this project",
"placeholder_search_mail": "Search mail",
"tooltip_email_blocked": "Email is currently blocked. Click to unblock.",
"tooltip_email_allowed": "Email is currently excempt from spam detection. Click to block.",
"tooltip_delete_email": "Delete entry, Email has to follow the regular rules.",
"export_all_comments": "Export all comments",
"export_warning_text": "This will export all comments of this project to all locations. Usually this is not needed, but can be helpful, if you have imported backups or similar.",
"wish_to_proceed": "Do you wish to proceed?",
"delete_project_warning": "You are about to delete a project. All associated data will be unrecoverably lost! Please perform a manual sql dump if you would like to retain that data.",
"javascript_required_field_empty": "A required field has been left empty!",
"javascript_invalid_project_name": "The project name is not valid. Please only use alphanumeric characters!",
"javascript_project_duplicate": "A project with this name already exists!",
"javascript_blogurl_invalid": "The blog-url is invalid!",
"javascript_output_nonexistent": "This output path does not exist!",
"javascript_gravatar_cache_nonexistent": "The cache path does not exist!",
"javascript_exception": "There was an unexpected exception. Please open an issue on Github!",
"javascript_edit_project_modal_title": "Edit project %name%",
"javascript_new_project_modal_title": "New Project",
"description_hugo_url": "URL of your Hugo site for this project",
"tooltip_hugo_url": "An URL is formed like this: https://example.com",
"tooltip_project_name": "Please select an unique name for your project.",
"description_output_path": "Hugo Data dir",
"tooltip_output_path": "The path to the data directory of your Hugo installation. Path can be relative.",
"description_gravatar_cache": "Cache Gravatar images locally?",
"tooltip_gravatar_cache": "If enabled, Labertasche will download gravatars to this location",
"tooltip_gravatar_dir": "The directory where to save the Gravatar images. Path can be relative.",
"description_gravatar_dir": "Gravatar caching directory.",
"tooltip_gravatar_size": "The numeric size of the images to download. Must be a power of 2, e.g 64, 128, 256",
"description_gravatar_size": "Gravatar image size",
"description_send_otp": "Send OTP to publish?",
"tooltip_send_otp": "If enabled, the user will be mailed a one time password to publish the comment (recommended). If disabled, it will be published by default (except spam).",
"description_enable_smileys": "Enable Smiley Addon?",
"tooltip_enable_smileys": "If enabled, simple text Smileys will be replaced with Emojis. Please see /etc/labertasche/smileys.yaml for more.",
"message_project_404": "The specified project was not found! Did you delete it? If you believe this to be a bug, please report it."
}

View File

@ -15,7 +15,6 @@
// post-internal-server-error
// post-success
// post-before-fetch
// post-project-not-found
function labertasche_callback(state)
{
if (state === "post-before-fetch"){
@ -37,7 +36,7 @@ function labertasche_callback(state)
// Callback for initiating and cancelling replies.
// Posstible message: 'on' and 'off'
function labertasche_reply_callback(state)
function labertasche_reply_callback()
{
if (state === "on"){
}
@ -87,12 +86,6 @@ function labertasche_post_comment(btn, callback)
return false;
}
// When there is no reply, use null, otherwise an error occurs on chrome
let reply_value = null
if (reply != null){
reply_value = reply.value;
}
callback('post-before-fetch');
fetch(remote,
{
@ -107,7 +100,7 @@ function labertasche_post_comment(btn, callback)
body: JSON.stringify({ "email": mail,
"content": comment,
"location": window.location.pathname,
"replied_to": reply_value
"replied_to": reply.value
})
})
.then(async function(response){

46
labertasche.yaml Normal file
View File

@ -0,0 +1,46 @@
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
system:
web_url: "http://comments.example.com" # Url where the comment system is served
blog_url: "http://myblog.example.com" # Url of your website
cookie-domain: "dev.localhost" # Url where the comment system is served
database_uri: "sqlite:///db/labertasche.db" # Database URI. Default is sqlite.
secret: "123456" # CHANGE ME! THIS IS IMPORTANT!
output: "/path/to/hugo_dir/data" # Base path for the output json
debug: false # Leave this as is, this is for development.
send_otp_to_publish: true # Disables confirmation w/ OTP via mail
gravatar:
cache: true # Enable caching of gravatar images
static_dir: "/path/to/hugo_dir//static/images/gravatar/" # Where to store cached images
size: 256 # only applies if images are cached,
# otherwise use ?s=size at the end of the gravatar url
dashboard:
username: "admin" # CHANGE ME!
password: "admin" # CHANGE ME!
addons:
smileys: true # Enable smiley replacements, set to false if unwanted
# If you want to expand this, please use this list:
# https://www.w3schools.com/charsets/ref_emoji_smileys.asp
# You need to both versions, upper and lowercase, if you want both to works
# This is a simple search and replace action
smileys:
":)": "&#128512"
":d": "&#128513"
":D": "&#128513"
";)": "&#128521"
":p": "&#128523"
":P": "&#128523"
":8": "&#128526"
"(:": "&#128579"
"$)": "&#129297"
":o": "&#128562"
":O": "&#128562"

View File

@ -13,8 +13,7 @@ from labertasche import (
blueprints,
helper,
mail,
settings,
language
settings
)
_all_ = [
@ -23,6 +22,5 @@ _all_ = [
blueprints,
helper,
mail,
settings,
language
settings
]

View File

@ -9,5 +9,4 @@
from .bp_comments import bp_comments
from .bp_login import bp_login
from .bp_dashboard import bp_dashboard
from .bp_jsconnector import bp_jsconnector
from .bp_upgrades import bp_dbupgrades

View File

@ -14,37 +14,30 @@ from flask_cors import cross_origin
from sqlalchemy import exc
from labertasche.database import labertasche_db as db
from labertasche.helper import is_valid_json, default_timestamp, check_gravatar, export_location
from labertasche.mail import Mail
from labertasche.models import TComments, TLocation, TEmail, TProjects
from labertasche.settings import Smileys
from labertasche.mail import mail
from labertasche.models import TComments, TLocation, TEmail
from labertasche.settings import Settings
from secrets import compare_digest
# Blueprint
bp_comments = Blueprint("bp_comments", __name__, url_prefix='/comments')
# Route for adding new comments
@bp_comments.route("/<name>/new", methods=['POST'])
def check_and_insert_new_comment(name):
# Get project
project = db.session.query(TProjects).filter(TProjects.name == name).first()
# Check refferer, this is not bullet proof
if not compare_digest(request.origin, project.blogurl):
return make_response(jsonify(status="not-allowed"), 403)
if not project:
return make_response(jsonify(status="post-project-not-found"), 400)
if compare_digest(request.method, "POST"):
smileys = Smileys()
sender = Mail()
@bp_comments.route("/new", methods=['POST'])
@cross_origin()
def check_and_insert_new_comment():
if request.method == 'POST':
settings = Settings()
smileys = settings.smileys
addons = settings.addons
sender = mail()
# Check length of content and abort if too long or too short
if request.content_length > 2048:
return make_response(jsonify(status="post-max-length"), 400)
if request.content_length == 0:
if request.content_length <= 0:
return make_response(jsonify(status="post-min-length"), 400)
# get json from request
@ -55,8 +48,9 @@ def check_and_insert_new_comment(name):
# Validate json and check length again
if not is_valid_json(new_comment) or \
len(new_comment['content']) < 40 or \
len(new_comment['email']) < 5:
len(new_comment['content']) < 40 or \
len(new_comment['email']) < 5:
print("too short", file=stderr)
return make_response(jsonify(status='post-invalid-json'), 400)
# Strip any HTML from message body
@ -65,9 +59,9 @@ def check_and_insert_new_comment(name):
content = re.sub(tags, '', new_comment['content']).strip()
content = re.sub(special, '', content).strip()
# Convert smileys if enabled
if project.addon_smileys:
for key, value in smileys.smileys.items():
# Convert smileys
if addons['smileys']:
for key, value in smileys.items():
content = content.replace(key, value)
# Validate replied_to field is integer
@ -114,8 +108,8 @@ def check_and_insert_new_comment(name):
is_spam = False
# Look for location
loc_query = db.session.query(TLocation) \
.filter(TLocation.location == new_comment['location'])
loc_query = db.session.query(TLocation)\
.filter(TLocation.location == new_comment['location'])
if loc_query.first():
# Location exists, set existing location id
@ -125,8 +119,7 @@ def check_and_insert_new_comment(name):
else:
# Insert new location
loc_table = {
'location': new_comment['location'],
'project_id': project.id_project
'location': new_comment['location']
}
new_loc = TLocation(**loc_table)
db.session.add(new_loc)
@ -138,17 +131,12 @@ def check_and_insert_new_comment(name):
new_comment.pop("location")
# insert comment
# noinspection PyBroadException
try:
if project.sendotp:
new_comment.update({"is_published": False})
else:
new_comment.update({"is_published": True})
new_comment.update({"is_published": False})
new_comment.update({"created_on": default_timestamp()})
new_comment.update({"is_spam": is_spam})
new_comment.update({"spam_score": has_score})
new_comment.update({"gravatar": check_gravatar(new_comment['email'], project.name)})
new_comment.update({"project_id": project.id_project})
new_comment.update({"gravatar": check_gravatar(new_comment['email'])})
t_comment = TComments(**new_comment)
db.session.add(t_comment)
db.session.commit()
@ -156,33 +144,33 @@ def check_and_insert_new_comment(name):
db.session.refresh(t_comment)
# Send confirmation link and store returned value
if project.sendotp:
hashes = sender.send_confirmation_link(new_comment['email'], project.name)
setattr(t_comment, "confirmation", hashes[0])
setattr(t_comment, "deletion", hashes[1])
db.session.commit()
hashes = sender.send_confirmation_link(new_comment['email'])
setattr(t_comment, "confirmation", hashes[0])
setattr(t_comment, "deletion", hashes[1])
db.session.commit()
except exc.IntegrityError as e:
# Comment body exists, because content is unique
print(f"Duplicate from {request.environ['REMOTE_ADDR']}, error is:\n{e}", file=stderr)
return make_response(jsonify(status="post-duplicate"), 400)
except Exception: # must be at bottom
except Exception as e: # must be at bottom
# mail(f"check_and_insert_new_comment has thrown an error: {e}", )
print("---------------------------------------------")
print(e, file=stderr)
print("---------------------------------------------")
return make_response(jsonify(status="post-internal-server-error"), 400)
export_location(t_comment.location_id)
return make_response(jsonify(status="post-success",
comment_id=t_comment.comments_id,
sendotp=project.sendotp), 200)
return make_response(jsonify(status="post-success", comment_id=t_comment.comments_id), 200)
# Route for confirming comments
@bp_comments.route("/<name>/confirm/<email_hash>", methods=['GET'])
@bp_comments.route("/confirm/<email_hash>", methods=['GET'])
@cross_origin()
def check_confirmation_link(name, email_hash):
def check_confirmation_link(email_hash):
settings = Settings()
comment = db.session.query(TComments).filter(TComments.confirmation == email_hash).first()
project = db.session.query(TProjects).filter(TProjects.name == name).first()
if comment:
location = db.session.query(TLocation).filter(TLocation.id_location == comment.location_id).first()
if compare_digest(comment.confirmation, email_hash):
@ -190,27 +178,27 @@ def check_confirmation_link(name, email_hash):
if not comment.is_spam:
setattr(comment, "is_published", True)
db.session.commit()
url = f"{project.blogurl}{location.location}#comment_{comment.comments_id}"
url = f"{settings.system['blog_url']}{location.location}#comment_{comment.comments_id}"
export_location(location.id_location)
return redirect(url)
return redirect(f"{project.blogurl}?cnf=true")
return redirect(f"{settings.system['blog_url']}?cnf=true")
# Route for deleting comments
@bp_comments.route("<name>/delete/<email_hash>", methods=['GET'])
@bp_comments.route("/delete/<email_hash>", methods=['GET'])
@cross_origin()
def check_deletion_link(name, email_hash):
project = db.session.query(TProjects).filter(TProjects.name == name).first()
comment = db.session.query(TComments).filter(TComments.deletion == email_hash).first()
def check_deletion_link(email_hash):
settings = Settings()
query = db.session.query(TComments).filter(TComments.deletion == email_hash)
comment = query.first()
if comment:
location = db.session.query(TLocation).filter(TLocation.id_location == comment.location_id).first()
if compare_digest(comment.deletion, email_hash):
db.session.delete(comment)
query.delete()
db.session.commit()
url = f"{project.blogurl}?deleted=true"
url = f"{settings.system['blog_url']}?deleted=true"
export_location(location.id_location)
return redirect(url)
return redirect(f"{project.blogurl}?cnf=true")
return redirect(f"{settings.system['blog_url']}?cnf=true")

View File

@ -0,0 +1,207 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from flask import Blueprint, render_template, request, redirect
from flask_login import login_required
from labertasche.database import labertasche_db as db
from labertasche.models import TLocation, TComments, TEmail
from labertasche.helper import dates_of_the_week, export_location
from sqlalchemy import func
import re
# Blueprint
bp_dashboard = Blueprint("bp_dashboard", __name__, url_prefix='/dashboard')
@bp_dashboard.route('/')
@login_required
def dashboard_index():
dates = dates_of_the_week()
spam = list()
published = list()
unpublished = list()
for each in dates:
spam_comments = db.session.query(TComments).filter(func.DATE(TComments.created_on) == each.date())\
.filter(TComments.is_spam == True).all()
pub_comments = db.session.query(TComments).filter(func.DATE(TComments.created_on) == each.date()) \
.filter(TComments.is_spam == False)\
.filter(TComments.is_published == True).all()
unpub_comments = db.session.query(TComments).filter(func.DATE(TComments.created_on) == each.date()) \
.filter(TComments.is_spam == False)\
.filter(TComments.is_published == False).all()
published.append(len(pub_comments))
spam.append(len(spam_comments))
unpublished.append(len(unpub_comments))
return render_template('dashboard.html', dates=dates, spam=spam, published=published, unpublished=unpublished)
@bp_dashboard.route('/review-spam/', methods=["POST", "GET"])
@bp_dashboard.route('/review-spam/<int:location_id>', methods=["POST", "GET"])
@login_required
def dashboard_review_spam(location_id=None):
all_locations = db.session.query(TLocation).all()
# Check post
if request.method == "POST":
location_id = request.form.get('selected_location')
# no parameters found
if location_id is None:
return render_template("review-spam.html", locations=all_locations, selected=location_id)
try:
if int(location_id) >= 1:
spam_comments = db.session.query(TComments).filter(TComments.location_id == location_id)\
.filter(TComments.is_spam == True)
return render_template("review-spam.html", locations=all_locations, selected=location_id,
spam_comments=spam_comments)
except ValueError:
pass
export_location(location_id)
return render_template("review-spam.html", locations=all_locations, selected=location_id)
@bp_dashboard.route('/manage-comments/', methods=["POST", "GET"])
@bp_dashboard.route('/manage-comments/<int:location_id>', methods=["POST", "GET"])
@login_required
def dashboard_manage_regular_comments(location_id=None):
all_locations = db.session.query(TLocation).all()
# Check post
if request.method == "POST":
location_id = request.form.get('selected_location')
# no parameters found
if location_id is None:
return render_template("manage-comments.html", locations=all_locations, selected=location_id)
try:
if int(location_id) >= 1:
spam_comments = db.session.query(TComments).filter(TComments.location_id == location_id) \
.filter(TComments.is_spam == False)
return render_template("manage-comments.html", locations=all_locations, selected=location_id,
spam_comments=spam_comments)
except ValueError:
pass
export_location(location_id)
return render_template("manage-comments.html", locations=all_locations, selected=location_id)
@bp_dashboard.route('/manage-mail/')
@login_required
def dashboard_allow_email():
addresses = db.session.query(TEmail).all()
return render_template("manage_mail_addresses.html", addresses=addresses)
@bp_dashboard.route('/toggle-mail-allowed/<int:id_email>')
@login_required
def dashboard_allow_email_toggle(id_email):
address = db.session.query(TEmail).filter(TEmail.id_email == id_email).first()
if address:
setattr(address, "is_allowed", (not address.is_allowed))
setattr(address, "is_blocked", (not address.is_blocked))
db.session.commit()
return redirect(request.referrer)
@bp_dashboard.route('/reset-mail-reputation/<int:id_email>')
@login_required
def dashboard_reset_mail_reputation(id_email):
db.session.query(TEmail).filter(TEmail.id_email == id_email).delete()
db.session.commit()
return redirect(request.referrer)
@bp_dashboard.route('/delete-comment/<int:location_id>/<int:comment_id>', methods=['GET'])
@login_required
def dashboard_review_spam_delete_comment(location_id, comment_id):
comment = db.session.query(TComments).filter(TComments.comments_id == comment_id).first()
db.session.delete(comment)
db.session.commit()
# Remove after last slash, to keep the location but get rid of the comment id
url = re.match("^(.*[/])", request.referrer)[0]
export_location(location_id)
return redirect(f"{url}/{location_id}")
@bp_dashboard.route('/allow-comment/<int:location_id>/<int:comment_id>', methods=['GET'])
@login_required
def dashboard_review_spam_allow_comment(comment_id, location_id):
comment = db.session.query(TComments).filter(TComments.comments_id == comment_id).first()
if comment:
setattr(comment, 'is_published', True)
setattr(comment, 'is_spam', False)
db.session.commit()
url = re.match("^(.*[/])", request.referrer)[0]
export_location(location_id)
return redirect(f"{url}/{location_id}")
@bp_dashboard.route('/block-mail/<int:location_id>/<int:comment_id>', methods=["GET"])
@login_required
def dashboard_review_spam_block_mail(location_id, comment_id):
comment = db.session.query(TComments).filter(TComments.comments_id == comment_id).first()
if comment:
mail = db.session.query(TEmail).filter(TEmail.email == comment.email).first()
if mail:
setattr(mail, 'is_allowed', False)
setattr(mail, 'is_blocked', True)
else:
new_mail = {
"email": comment.first().email,
"is_allowed": False,
"is_blocked": True
}
db.session.add(TEmail(**new_mail))
# Delete all comments made by this mail address
db.session.query(TComments).filter(TComments.email == comment.email).delete()
db.session.commit()
url = re.match("^(.*[/])", request.referrer)[0]
export_location(location_id)
return redirect(f"{url}/{location_id}")
@bp_dashboard.route('/allow-user/<int:location_id>/<int:comment_id>', methods=["GET"])
@login_required
def dashboard_review_spam_allow_user(location_id, comment_id):
comment = db.session.query(TComments).filter(TComments.comments_id == comment_id).first()
if comment:
mail = db.session.query(TEmail).filter(TEmail.email == comment.email).first()
if mail:
setattr(mail, 'is_allowed', True)
setattr(mail, 'is_blocked', False)
else:
new_mail = {
"email": comment.email,
"is_allowed": True,
"is_blocked": False
}
db.session.add(TEmail(**new_mail))
# Allow all comments made by this mail address
all_comments = db.session.query(TComments).filter(TComments.email == comment.email).all()
if all_comments:
for comment in all_comments:
setattr(comment, 'is_published', True)
setattr(comment, 'is_spam', False)
db.session.commit()
url = re.match("^(.*[/])", request.referrer)[0]
export_location(location_id)
return redirect(f"{url}/{location_id}")

View File

@ -1,18 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from flask import Blueprint
# Blueprint
bp_dashboard = Blueprint("bp_dashboard", __name__, url_prefix='/dashboard')
# Files with routes
from .projects import dashboard_project_list
from .mail import dashboard_manage_mail
from .spam import dashboard_review_spam
from .comments import dashboard_manage_regular_comments

View File

@ -1,68 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from . import bp_dashboard
from flask import render_template, request, redirect, url_for
from flask_login import login_required
from flask_cors import cross_origin
from labertasche.database import labertasche_db as db
from labertasche.models import TLocation, TComments
from labertasche.helper import export_location, get_id_from_project_name
@cross_origin
@bp_dashboard.route('/<project>/manage-comments/', methods=["GET"])
@login_required
def dashboard_manage_regular_comments(project: str):
location_id = 0
proj_id = get_id_from_project_name(project)
all_locations = db.session.query(TLocation)\
.filter(TLocation.project_id == proj_id)\
.all()
# Check if there is a comment, otherwise don't show on management page
# This can happen when the last comment was deleted, the location
# won't be removed.
tmp_list = list()
for each in all_locations:
comment_count = db.session.query(TComments.comments_id)\
.filter(TComments.location_id == each.id_location)\
.filter(TComments.is_spam == False) \
.count()
if comment_count > 0:
tmp_list.append(each)
all_locations = tmp_list
# Project does not exist, error code is used by Javascript, not Flask
if proj_id == -1:
return redirect(url_for("bp_dashboard.dashboard_project_list", error=404))
if request.args.get('location'):
location_id = request.args.get('location')
# no parameters found
if location_id is None:
return render_template("manage-comments.html", locations=all_locations,
selected=location_id, title="Manage Comments",
action="comments")
try:
if int(location_id) >= 1:
spam_comments = db.session.query(TComments).filter(TComments.location_id == location_id) \
.filter(TComments.is_spam == False)
return render_template("manage-comments.html", locations=all_locations, selected=location_id,
spam_comments=spam_comments, project=project,
title="Manage Comments", action="comments")
except ValueError:
pass
return render_template("manage-comments.html", locations=all_locations,
selected=location_id, project=project, title="Manage Comments",
action="comments")

View File

@ -1,30 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from . import bp_dashboard
from flask import render_template
from flask_login import login_required
from flask_cors import cross_origin
from labertasche.database import labertasche_db as db
from labertasche.models import TEmail
# noinspection PyUnusedLocal
@cross_origin()
@bp_dashboard.route('/manage-mail/')
@bp_dashboard.route('/<project>/manage-mail/')
@login_required
def dashboard_manage_mail(project: str = None):
"""
Shows the panel to manage email addresses
:param project: Not used
:return: The template used to display the route
"""
addresses = db.session.query(TEmail).all()
return render_template("manage-mail.html", addresses=addresses)

View File

@ -1,109 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from . import bp_dashboard
from flask import render_template, redirect, url_for, request
from flask_login import login_required
from flask_cors import cross_origin
from sqlalchemy import func
from sqlalchemy.exc import OperationalError
from labertasche.database import labertasche_db as db
from labertasche.models import TComments, TProjects
from labertasche.helper import get_id_from_project_name, dates_of_the_week
@cross_origin
@bp_dashboard.route("/")
@login_required
def dashboard_project_list():
"""
Displays an overview of all projects.
:return: The overview template.
"""
try:
t_projects = db.session.query(TProjects).all()
except OperationalError:
# Database not up-to-date
return redirect(url_for('bp_dbupgrades.upgrade_db_to_v2'))
projects = list()
for each in t_projects:
comments = db.session.query(TComments).filter(TComments.project_id == each.id_project) \
.filter(TComments.is_published == True) \
.filter(TComments.is_spam == False).count()
unpub_comments = db.session.query(TComments).filter(TComments.project_id == each.id_project) \
.filter(TComments.is_spam == False) \
.filter(TComments.is_published == False).count()
spam = db.session.query(TComments).filter(TComments.project_id == each.id_project) \
.filter(TComments.is_spam == True).count()
projects.append(dict({
"id_project": each.id_project,
"name": each.name,
"total_comments": comments,
"total_spam": spam,
"total_unpublished": unpub_comments
}))
return render_template('project-list.html', projects=projects)
@cross_origin
@bp_dashboard.route('/<project>/')
@login_required
def dashboard_project_stats(project: str):
"""
Displays the project dashboard
:param project: The project to show
:return: The template for the route
"""
proj_id = get_id_from_project_name(project)
# Project does not exist, error code is used by Javascript, not Flask
if proj_id == -1:
return redirect(url_for("bp_dashboard.dashboard_project_list", error=404))
# Total graphs
total_spam = db.session.query(TComments).filter(TComments.is_spam == True).count()
total_comments = db.session.query(TComments) \
.filter(TComments.is_spam == False)\
.filter(TComments.is_published == True).count()
total_unpublished = db.session.query(TComments).filter(TComments.is_spam == False)\
.filter(TComments.is_published == False).count()
# 7 day graph
dates = dates_of_the_week()
spam = list()
published = list()
unpublished = list()
for each in dates:
spam_comments = db.session.query(TComments).filter(TComments.project_id == proj_id) \
.filter(func.DATE(TComments.created_on) == each.date()) \
.filter(TComments.is_spam == True).all()
pub_comments = db.session.query(TComments).filter(func.DATE(TComments.created_on) == each.date()) \
.filter(TComments.project_id == proj_id) \
.filter(TComments.is_spam == False) \
.filter(TComments.is_published == True).all()
unpub_comments = db.session.query(TComments).filter(func.DATE(TComments.created_on) == each.date()) \
.filter(TComments.project_id == proj_id) \
.filter(TComments.is_spam == False) \
.filter(TComments.is_published == False).all()
published.append(len(pub_comments))
spam.append(len(spam_comments))
unpublished.append(len(unpub_comments))
return render_template('project-stats.html', dates=dates, spam=spam, project=project,
published=published, unpublished=unpublished,
total_spam=total_spam, total_comments=total_comments,
total_unpublished=total_unpublished)

View File

@ -1,72 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from . import bp_dashboard
from flask import render_template, request, redirect, url_for
from flask_login import login_required
from flask_cors import cross_origin
from labertasche.database import labertasche_db as db
from labertasche.models import TLocation, TComments
from labertasche.helper import export_location, get_id_from_project_name
@cross_origin()
@bp_dashboard.route('<project>/manage-spam/', methods=["GET"])
@login_required
def dashboard_review_spam(project: str):
"""
Shows the manage spam template
:param project: The project used for displaying data
:return: The template to display for this rouet
"""
location_id = 0
proj_id = get_id_from_project_name(project)
all_locations = db.session.query(TLocation).filter(TLocation.project_id == proj_id).all()
# Check if there is a comment, otherwise don't show on management page
# This can happen when the last comment was deleted, the location
# won't be removed.
tmp_list = list()
for each in all_locations:
comment_count = db.session.query(TComments.comments_id) \
.filter(TComments.location_id == each.id_location) \
.filter(TComments.is_spam == True) \
.count()
if comment_count > 0:
tmp_list.append(each)
all_locations = tmp_list
# Project does not exist, error code is used by Javascript, not Flask
if proj_id == -1:
return redirect(url_for("bp_dashboard.dashboard_project_list", error=404))
if request.args.get('location'):
location_id = request.args.get('location')
# no parameters found
if location_id is None:
return render_template("manage-comments.html", locations=all_locations,
selected=location_id, title="Review Spam", action="spam")
try:
if int(location_id) >= 1:
spam_comments = db.session.query(TComments) \
.filter(TComments.project_id == proj_id) \
.filter(TComments.location_id == location_id) \
.filter(TComments.is_spam == True)
return render_template("manage-comments.html", locations=all_locations, selected=location_id,
spam_comments=spam_comments, project=project, title="Review Spam", action="spam")
except ValueError:
pass
export_location(location_id)
return render_template("manage-comments.html", locations=all_locations,
selected=location_id, project=project, title="Review Spam", action="spam")

View File

@ -1,18 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from flask import Blueprint
# Blueprint
bp_jsconnector = Blueprint("bp_jsconnector", __name__, url_prefix='/api/')
from .projects import api_create_project, api_delete_project, api_edit_project_name
from .mail import api_toggle_email_reputation, api_reset_mail_reputation
from .language import api_translation
from .comments import api_comment_allow_user, api_comment_allow_comment, \
api_comment_block_mail, api_comments_delete_comment

View File

@ -1,130 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from . import bp_jsconnector
from flask import request, redirect, make_response, jsonify
from flask_login import login_required
from flask_cors import cross_origin
from labertasche.database import labertasche_db as db
from labertasche.helper import export_location, get_id_from_project_name
from labertasche.models import TComments, TEmail, TLocation
# This file contains the routes for the manage comments menu point.
# They are called via GET
@cross_origin()
@bp_jsconnector.route('/comment-delete/<int:comment_id>', methods=['GET'])
@login_required
def api_comments_delete_comment(comment_id):
db.session.query(TComments).filter(TComments.comments_id == comment_id).delete()
db.session.commit()
# Get location id from get params
location_id = request.args.get('location')
export_location(location_id)
return redirect(request.referrer)
@cross_origin()
@bp_jsconnector.route('/comment-allow/<int:comment_id>', methods=['GET'])
@login_required
def api_comment_allow_comment(comment_id):
comment = db.session.query(TComments).filter(TComments.comments_id == comment_id).first()
if comment:
setattr(comment, 'is_published', True)
setattr(comment, 'is_spam', False)
db.session.commit()
# Get location id from get params
location_id = request.args.get('location')
export_location(location_id)
return redirect(request.referrer)
@cross_origin()
@bp_jsconnector.route('/comment-allow-user/<int:comment_id>', methods=["GET"])
@login_required
def api_comment_allow_user(comment_id):
comment = db.session.query(TComments).filter(TComments.comments_id == comment_id).first()
if comment:
addr = db.session.query(TEmail).filter(TEmail.email == comment.email).first()
if addr:
setattr(addr, 'is_allowed', True)
setattr(addr, 'is_blocked', False)
else:
new_mail = {
"email": comment.email,
"is_allowed": True,
"is_blocked": False
}
db.session.add(TEmail(**new_mail))
# Allow all comments made by this mail address
all_comments = db.session.query(TComments).filter(TComments.email == comment.email).all()
if all_comments:
for comment in all_comments:
setattr(comment, 'is_published', True)
setattr(comment, 'is_spam', False)
db.session.commit()
# Get location id from get params
location_id = request.args.get('location')
export_location(location_id)
return redirect(request.referrer)
@cross_origin()
@bp_jsconnector.route('/comment-block-mail/<int:comment_id>', methods=["GET"])
@login_required
def api_comment_block_mail(comment_id):
comment = db.session.query(TComments).filter(TComments.comments_id == comment_id).first()
if comment:
addr = db.session.query(TEmail).filter(TEmail.email == comment.email).first()
if addr:
setattr(addr, 'is_allowed', False)
setattr(addr, 'is_blocked', True)
else:
new_mail = {
"email": comment.first().email,
"is_allowed": False,
"is_blocked": True
}
db.session.add(TEmail(**new_mail))
# Delete all comments made by this mail address
db.session.query(TComments).filter(TComments.email == comment.email).delete()
db.session.commit()
# Get location id from get params
location_id = request.args.get('location')
export_location(location_id)
return redirect(request.referrer)
@cross_origin()
@bp_jsconnector.route('/comment-export-all/<name>', methods=["GET"])
@login_required
def api_export_all_by_project(name):
proj_id = get_id_from_project_name(name)
if proj_id == -1:
return make_response(jsonify(status='not-found'), 400)
try:
locations = db.session.query(TLocation).all()
for each in locations:
export_location(each.id_location)
except Exception as e:
return make_response(jsonify(status='sql-error', msg=str(e)), 400)
return make_response(jsonify(status='ok'), 200)

View File

@ -1,21 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from . import bp_jsconnector
from flask import make_response, jsonify, request
from flask_cors import cross_origin
from flask_login import login_required
from labertasche.language import Language
@cross_origin
@bp_jsconnector.route('/language/')
@login_required
def api_translation():
lang = Language(request=request)
return make_response(jsonify(lang.i18n), 200)

View File

@ -1,37 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from . import bp_jsconnector
from flask import request, redirect
from flask_login import login_required
from flask_cors import cross_origin
from labertasche.database import labertasche_db as db
from labertasche.models import TEmail
@cross_origin()
@bp_jsconnector.route('/mail-toggle-status/<int:id_email>')
@login_required
def api_toggle_email_reputation(id_email):
address = db.session.query(TEmail).filter(TEmail.id_email == id_email).first()
if address:
setattr(address, "is_allowed", (not address.is_allowed))
setattr(address, "is_blocked", (not address.is_blocked))
db.session.commit()
return redirect(request.referrer)
@cross_origin()
@bp_jsconnector.route('/mail-reset-reputation/<int:id_email>')
@login_required
def api_reset_mail_reputation(id_email):
db.session.query(TEmail).filter(TEmail.id_email == id_email).delete()
db.session.commit()
return redirect(request.referrer)

View File

@ -1,191 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from . import bp_jsconnector
from flask import request, make_response, jsonify
from flask_login import login_required
from flask_cors import cross_origin
from labertasche.database import labertasche_db as db
from labertasche.helper import get_id_from_project_name
from labertasche.models import TProjects, TComments, TEmail, TLocation
from validators import url as validate_url
from pathlib import Path
from secrets import compare_digest
import re
def validate_project(project, is_edit=False):
"""
Validates important bits of a project database entry
:param project: The json from the request, containing the data for a project.
:param is_edit: If we are updating the database, we need to know, so we don't check for dupes on urls.
:return: A response with the error or None if the project is valid.
"""
# Validate length
if not len(project['name']) and \
not len(project['blogurl']) and \
not len(project['output']):
return make_response(jsonify(status='too-short'), 400)
# Validate project name
if not re.match('^\\w+$', project['name']):
return make_response(jsonify(status='invalid-project-name'), 400)
# Check if project name already exists
name_check = db.session.query(TProjects.name).filter(TProjects.name == project['name']).first()
if name_check and not is_edit:
return make_response(jsonify(status='project-exists'), 400)
# Validate existing only if we are not editing
url_exists = False
if not is_edit:
url_exists = db.session.query(TProjects.blogurl).filter(TProjects.blogurl == project['blogurl']).first()
if not validate_url(project['blogurl']) or url_exists:
return make_response(jsonify(status='invalid-blog-url'), 400)
# Validate output path
output = Path(project['output']).absolute()
# The second check is needed, since javascript is passing an empty string instead of
# null. For some reason, this makes SQLAlchemy accept the data and commit it to the db
# without exception. This check prevents this issue from happening.
if not output.exists() or len(project['output'].strip()) == 0:
return make_response(jsonify(status='invalid-path-output'), 400)
# Validate cache path, if caching is enabled
if project['gravatar_cache']:
cache = Path(project['gravatar_cache_dir']).absolute()
if not cache.exists() or len(project['gravatar_cache_dir'].strip()) == 0:
return make_response(jsonify(status='invalid-path-cache'), 400)
return None
@cross_origin()
@bp_jsconnector.route("/project/new", methods=['POST'])
@login_required
def api_create_project():
"""
Called on dashboard project overview to create a new project.
:return: A string with an error code and 'ok' as string on success.
"""
response = validate_project(request.json)
if response is not None:
return response
try:
new_project = request.json
# Remove trailing slash
if compare_digest(new_project['blogurl'][-1], '/'):
new_project['blogurl'] = new_project['blogurl'][:-1]
db.session.add(TProjects(**new_project))
db.session.commit()
except Exception as e:
print(str(e))
db.session.rollback()
return make_response(jsonify(status='exception', msg=str(e)), 500)
return make_response(jsonify(status='ok'), 200)
@cross_origin()
@bp_jsconnector.route('/project/edit/<name>', methods=['POST'])
@login_required
def api_edit_project_name(name: str):
"""
Renames the project.
:param name: The previous name of the project to edit, must exist
:return: A string with an error code and 'ok' as string on success.
"""
response = validate_project(request.json, is_edit=True)
if response is not None:
return response
try:
project = db.session.query(TProjects).filter(TProjects.name == name).first()
project_json = request.json
# Remove trailing slash to streamline it
if compare_digest(project_json['blogurl'][-1], '/'):
setattr(project, "blogurl", project_json['blogurl'][:-1].strip())
setattr(project, "id_project", project.id_project)
setattr(project, "name", project_json['name'])
setattr(project, "output", project_json['output'].strip())
setattr(project, "sendotp", project_json['sendotp'])
setattr(project, "gravatar_cache", project_json['gravatar_cache'])
setattr(project, "gravatar_cache_dir", project_json['gravatar_cache_dir'])
setattr(project, "gravatar_size", project_json['gravatar_size'])
setattr(project, "addon_smileys", project_json['addon_smileys'])
db.session.commit()
except Exception as e:
print(str(e))
return make_response(jsonify(status='exception', msg=str(e)), 500)
return make_response(jsonify(status='ok'), 200)
@cross_origin()
@bp_jsconnector.route('/project/delete/<name>', methods=['GET'])
@login_required
def api_delete_project(name: str):
"""
Deletes a project from the database and all associated data
:param name: The name of the project
:return: A string with an error code and 'ok' as string on success.
"""
proj_id = get_id_from_project_name(name)
if proj_id == -1:
return make_response(jsonify(status='not-found'), 400)
# noinspection PyBroadException
try:
db.session.query(TComments).filter(TComments.project_id == proj_id).delete()
db.session.query(TLocation).filter(TLocation.project_id == proj_id).delete()
db.session.query(TProjects).filter(TProjects.id_project == proj_id).delete()
db.session.commit()
db.session.flush()
except Exception:
return make_response(jsonify(status='exception'), 400)
return make_response(jsonify(status='ok'), 200)
@cross_origin()
@bp_jsconnector.route('/project/exists/<name>', methods=['GET'])
@login_required
def api_project_exists(name: str):
proj_id = get_id_from_project_name(name)
if proj_id == -1:
return make_response(jsonify(status='not-found'), 200)
return make_response(jsonify(status='ok'), 200)
@cross_origin()
@bp_jsconnector.route('/project/get/<name>', methods=['GET'])
@login_required
def api_project_get_data(name: str):
project = db.session.query(TProjects).filter(TProjects.name == name).first()
if project:
return make_response(jsonify(status='ok',
id_project=project.id_project,
name=project.name,
blogurl=project.blogurl,
output=project.output,
sendotp=project.sendotp,
gravatar_cache=project.gravatar_cache,
gravatar_cache_dir=project.gravatar_cache_dir,
gravatar_size=project.gravatar_size,
addon_smileys=project.addon_smileys), 200)
else:
print('400')
return make_response(jsonify(status='not-found'), 400)

View File

@ -8,9 +8,7 @@
# *********************************************************************************/
from flask import Blueprint, render_template, request, redirect, url_for
from flask_cors import cross_origin
from labertasche.helper import User
from labertasche.settings import Credentials
from secrets import compare_digest
from labertasche.helper import check_auth, User
from flask_login import login_user, current_user, logout_user
# Blueprint
@ -21,7 +19,7 @@ bp_login = Blueprint("bp_login", __name__)
@bp_login.route('/', methods=['GET'])
def show_login():
if current_user.is_authenticated:
return redirect(url_for('bp_dashboard.dashboard_project_list'))
return redirect(url_for('bp_dashboard.dashboard_index'))
return render_template('login.html')
@ -32,11 +30,9 @@ def login():
username = request.form['username']
password = request.form['password']
credentials = Credentials()
if compare_digest(username.encode('utf8'), credentials.username.encode('utf8')) and \
credentials.compare_password(password):
if check_auth(username, password):
login_user(User(0), remember=True)
return redirect(url_for('bp_dashboard.dashboard_project_list'))
return redirect(url_for('bp_dashboard.dashboard_index'))
# Redirect get request to the login page
return redirect(url_for('bp_login.show_login'))

View File

@ -1,14 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from flask import Blueprint
# Blueprint
bp_dbupgrades = Blueprint("bp_dbupgrades", __name__, url_prefix='/upgrade')
from .db_v2 import upgrade_db_to_v2

View File

@ -1,235 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from . import bp_dbupgrades
from flask_cors import cross_origin
from flask_login import login_required
from flask import render_template, jsonify, make_response, redirect, url_for, current_app
from pathlib import Path
from labertasche.database import labertasche_db as db
from labertasche.models import TProjects, TComments, TLocation, TEmail, TVersion
from labertasche.settings import LegacySettings
from json import dump, load
from shutil import copy, make_archive
from re import search
from secrets import compare_digest
from datetime import datetime
def get_backup_folder() -> Path:
path = Path(current_app.root_path)
path = path / "backup" / "v1"
return path
@cross_origin()
@bp_dbupgrades.route('/db_v2/')
@login_required
def upgrade_db_to_v2():
# TODO: Check if db has already been upgraded
status = False
try:
version = db.session.query(TVersion).first()
if version:
status = True
return redirect(url_for('bp_dashboard.dashboard_project_list'))
except Exception as e:
print(e.__class__)
pass
return render_template("db-upgrades.html", title="DB upgrade V1 to V2",
prev_version=1, new_version=2, status=status)
@cross_origin()
@bp_dbupgrades.route('/db_v2/backup/', methods=['GET'])
@login_required
def upgrade_db_to_v2_backup():
path = get_backup_folder()
# Create path for backup
try:
if not path.exists():
path.mkdir(mode=777, exist_ok=True, parents=True)
except OSError as e:
return make_response(jsonify(status='exception', msg=str(e)), 400)
return make_response(jsonify(status="ok"), 200)
@cross_origin()
@bp_dbupgrades.route('/db_v2/export/')
@login_required
def upgrade_db_to_v2_export():
path = get_backup_folder()
# make sure nothing is pending
db.session.commit()
# Export tables
t_locations = db.session.query(TLocation.id_location, TLocation.location).all()
t_emails = db.session.query(TEmail.id_email, TEmail.email, TEmail.is_allowed, TEmail.is_blocked).all()
t_comments = db.session.query(TComments.comments_id, TComments.location_id, TComments.email,
TComments.content, TComments.created_on, TComments.is_published,
TComments.is_spam, TComments.spam_score, TComments.replied_to,
TComments.confirmation, TComments.deletion, TComments.gravatar).all()
locations = []
for loc in t_locations:
locations.append({
"id_location": loc.id_location,
"location": loc.location
})
emails = []
for mail in t_emails:
emails.append({
"id_email": mail.id_email,
"email": mail.email,
"is_allowed": mail.is_allowed,
"is_blocked": mail.is_blocked
})
comments = []
for comment in t_comments:
comments.append({
"comments_id": comment.comments_id,
"location_id": comment.location_id,
"email": comment.email,
"content": comment.content,
"created_on": f"{comment.created_on.__str__()}",
"is_published": comment.is_published,
"is_spam": comment.is_spam,
"spam_score": comment.spam_score,
"replied_to": comment.replied_to,
"confirmation": comment.confirmation,
"deletion": comment.deletion,
"gravatar": comment.gravatar
})
# Output jsons
try:
p_export_location = path / "locations.json"
with p_export_location.open('w') as fp:
dump(locations, fp, indent=4, sort_keys=True)
p_export_mail = path / "emails.json"
with p_export_mail.open('w') as fp:
dump(emails, fp, indent=4, sort_keys=True)
p_export_comments = path / "comments.json"
with p_export_comments.open('w') as fp:
dump(comments, fp, indent=4, sort_keys=True)
except Exception as e:
return make_response(jsonify(status='exception-write-json', msg=str(e)), 400)
# Copy database
try:
settings = LegacySettings(True)
db_uri = settings.system['database_uri']
if compare_digest(db_uri[0:6], "sqlite"):
m = search("([/]{3})(.*)", db_uri)
new_db = get_backup_folder() / "labertasche.db"
old_db = Path(current_app.root_path)
old_db = old_db / m.group(2)
copy(old_db.absolute(), new_db.absolute())
except Exception as e:
return make_response(jsonify(status='exception-copy-db', msg=str(e)), 400)
make_archive(path, "zip", path)
return make_response(jsonify(status='ok'), 200)
@cross_origin()
@bp_dbupgrades.route('/db_v2/recreate/')
@login_required
def upgrade_db_to_v2_recreate():
try:
db.drop_all()
db.session.flush()
db.session.commit()
db.create_all()
except Exception as e:
return make_response(jsonify(status='exception', msg=str(e)), 400)
return make_response(jsonify(status='ok'), 200)
@cross_origin()
@bp_dbupgrades.route('/db_v2/import/')
@login_required
def upgrade_db_to_v2_import():
path = get_backup_folder()
settings = LegacySettings(True)
try:
# load location
p_loc = (path / 'locations.json').absolute()
with p_loc.open('r') as fp:
locations = load(fp)
# load mails
m_loc = (path / 'emails.json').absolute()
with m_loc.open('r') as fp:
mails = load(fp)
# load comments
c_loc = (path / 'comments.json').absolute()
with c_loc.open('r') as fp:
comments = load(fp)
except FileNotFoundError as e:
return make_response(jsonify(status='exception-filenotfound', msg=str(e)), 400)
# Create project
default_project = {
"id_project": 1,
"name": "default",
"blogurl": settings.system['blog_url'],
"output": settings.system['output'],
"sendotp": settings.system['send_otp_to_publish'],
"gravatar_cache": settings.gravatar['cache'],
"gravatar_cache_dir": settings.gravatar['static_dir'],
"gravatar_size": settings.gravatar['size'],
"addon_smileys": settings.addons['smileys']
}
# Create db version, so we can track it in the future
version = {
"id_version": 1,
"version": 2
}
try:
# Add to db
db.session.add(TVersion(**version))
db.session.add(TProjects(**default_project))
# walk json and readd to database with project set to project 1
for each in mails:
db.session.add(TEmail(**each))
for each in locations:
each.update({'project_id': 1})
db.session.add(TLocation(**each))
for each in comments:
each.update({'project_id': 1})
dt = datetime.fromisoformat(each['created_on'])
each.update({'created_on': dt})
db.session.add(TComments(**each))
# Commit
db.session.commit()
db.session.flush()
except Exception as e:
return make_response(jsonify(status='exception-database', msg=str(e)), 400)
return make_response(jsonify(status='ok'), 200)

View File

@ -7,20 +7,6 @@
# * _license : This project is under MIT License
# *********************************************************************************/
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import MetaData
from sqlalchemy.pool import NullPool
# naming conventions
convention = {
"ix": 'ix_%(column_0_label)s',
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(column_0_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}
metadata = MetaData(naming_convention=convention)
# Create SQLAlchemy
labertasche_db = SQLAlchemy(metadata=metadata, engine_options={
'poolclass': NullPool
})
labertasche_db = SQLAlchemy()

View File

@ -8,19 +8,21 @@
# *********************************************************************************/
import datetime
import json
from labertasche.models import TLocation, TComments
from labertasche.settings import Settings
from labertasche.database import labertasche_db as db
from functools import wraps
from hashlib import md5
from flask import request
from flask_login import UserMixin
from secrets import compare_digest
from pathlib import Path
from sys import stderr
from re import match as re_match
from labertasche.models import TLocation, TComments, TProjects
from labertasche.database import labertasche_db as db
import requests
class User(UserMixin):
"""
Class for flask-login, which represents a user
"""
def __init__(self, user_id):
self.id = user_id
@ -35,7 +37,7 @@ def is_valid_json(j):
try:
json.dumps(j)
return True
except json.JSONDecodeError:
except json.JSONDecodeError as e:
print("not valid json")
return False
@ -75,30 +77,61 @@ def alchemy_query_to_dict(obj):
# Come on, it's a mail hash, don't complain
# noinspection InsecureHash
def check_gravatar(email: str, name: str):
def check_gravatar(email: str):
"""
Builds the gravatar email hash, which uses md5.
You may use ?size=128 for example to dictate size in the final template.
:param email: the email to use for the hash
:param name: The project name
:return: the gravatar url of the image
"""
from requests import get
project = db.session.query(TProjects).filter(TProjects.name == name).first()
settings = Settings()
options = settings.gravatar
gravatar_hash = md5(email.strip().lower().encode("utf8")).hexdigest()
if project.gravatar_cache:
url = f"https://www.gravatar.com/avatar/{gravatar_hash}?s={project.gravatar_size}"
response = get(url)
if options['cache']:
url = f"https://www.gravatar.com/avatar/{gravatar_hash}?s={options['size']}"
response = requests.get(url)
if response.ok:
outfile = Path(f"{project.gravatar_cache_dir}/{gravatar_hash}.jpg")
with outfile.open('wb') as fp:
response.raw.decode_content = True
for chunk in response:
fp.write(chunk)
outfile = Path(f"{options['static_dir']}/{gravatar_hash}.jpg")
if not outfile.exists():
with outfile.open('wb') as fp:
response.raw.decode_content = True
for chunk in response:
fp.write(chunk)
return gravatar_hash
def check_auth(username: str, password: str):
"""
Compares username and password from the settings file in a safe way.
Direct string comparison is vulnerable to timing attacks
https://sqreen.github.io/DevelopersSecurityBestPractices/timing-attack/python
:param username: username entered by the user
:param password: password entered by the user
:return: True if equal, False if not
"""
settings = Settings()
if compare_digest(username, settings.dashboard['username']) and \
compare_digest(password, settings.dashboard['password']):
return True
return False
def basic_login_required(f):
"""
Decorator for basic auth
"""
@wraps(f)
def wrapped_view(**kwargs):
auth = request.authorization
if not (auth and check_auth(auth.username, auth.password)):
return ('Unauthorized', 401, {
'WWW-Authenticate': 'Basic realm="Login Required"'
})
return f(**kwargs)
return wrapped_view
def export_location(location_id: int) -> bool:
"""
Exports the comments for the location after the comment was accepted
@ -109,28 +142,12 @@ def export_location(location_id: int) -> bool:
db.session.flush()
# Query
location = db.session.query(TLocation).filter(TLocation.id_location == location_id).first()
loc_query = db.session.query(TLocation).filter(TLocation.id_location == location_id).first()
if location:
if loc_query:
comments = db.session.query(TComments).filter(TComments.is_spam != True) \
.filter(TComments.is_published == True) \
.filter(TComments.location_id == location.id_location) \
.filter(TProjects.id_project == location.project_id) \
.all()
project = db.session.query(TProjects).filter(TProjects.id_project == location.project_id).first()
# Removes the last slash
path_loc = re_match(".*(?=/)", location.location)[0]
# Construct export path
jsonfile = Path(f"{project.output}/{path_loc}.json").absolute()
folder = jsonfile.parents[0]
# If there are no comments, do not export and remove empty file.
# The database is the point of trust.
if len(comments) == 0:
jsonfile.unlink(missing_ok=True)
return True
.filter(TComments.location_id == loc_query.id_location)
bundle = {
"comments": [],
@ -142,14 +159,20 @@ def export_location(location_id: int) -> bool:
continue
bundle['comments'].append(alchemy_query_to_dict(comment))
# Create folder if not exists and write file
path_loc = re_match(".*(?=/)", loc_query.location)[0]
system = Settings().system
out = Path(f"{system['output']}/{path_loc}.json")
out = out.absolute()
folder = out.parents[0]
folder.mkdir(parents=True, exist_ok=True)
with jsonfile.open('w') as fp:
with out.open('w') as fp:
json.dump(bundle, fp)
return True
except Exception as e:
# mail(f"export_comments has thrown an error: {str(e)}")
print(e, file=stderr)
return False
@ -170,17 +193,3 @@ def dates_of_the_week():
date_list.append(monday)
date_list.append((monday + datetime.timedelta(days=1, hours=23, minutes=59, seconds=59)))
return date_list
def get_id_from_project_name(name: str) -> int:
"""
Returns the id of a project name
:param name: The display name of the project
:return: the ID of the project
"""
proj = db.session.query(TProjects).filter(TProjects.name == name).first()
if proj is None:
return -1
return proj.id_project

View File

@ -1,48 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from flask import Request, current_app
from pathlib import Path
from json import load
class Language:
def __init__(self, request: Request):
# Define data
self.i18n = dict()
self.languages = list()
# Directory where translations live
i18n_dir = Path(current_app.root_path).absolute()
i18n_dir = i18n_dir / "i18n"
# Looks for translations
for filename in i18n_dir.glob("*.json"):
if filename.is_file():
self.languages.append(filename.stem)
# Check the browser language in the headers
self.browser_language = request.accept_languages.best_match(self.languages, default="en-US")
# Try to Load language accepted by browser
try:
file = i18n_dir / self.browser_language
with file.with_suffix(".json").absolute().open('r', encoding='utf-8') as fp:
foreign = load(fp)
except FileNotFoundError:
pass
# Always load english
file = i18n_dir / "en-US"
with file.with_suffix(".json").absolute().open('r', encoding='utf-8') as fp:
self.i18n = load(fp)
# Merge dicts, so missing keys are replaced with English
self.i18n.update(**foreign)

View File

@ -13,16 +13,12 @@ from pathlib import Path
from platform import system
from smtplib import SMTP_SSL, SMTPHeloError, SMTPAuthenticationError, SMTPException
from ssl import create_default_context
from labertasche.settings import Settings
from validate_email import validate_email_or_fail
from secrets import token_urlsafe
from labertasche.models import TProjects
from labertasche.database import labertasche_db as db
from labertasche.settings import Settings
from labertasche.language import Language
from flask import render_template, request
class Mail:
class mail:
def __init__(self):
path = Path("/etc/labertasche/mail_credentials.json")
@ -65,45 +61,32 @@ class Mail:
except SMTPException as e:
print(f"SMTPException: {e}")
def send_confirmation_link(self, email: str, name: str) -> tuple:
def send_confirmation_link(self, email):
"""
Send confirmation link after entering a comment
:param email: The address to send the mail to
:param name: The name of the project
:return: A tuple with the confirmation token and the deletion token, in this order
"""
project = db.session.query(TProjects).filter(TProjects.name == name).first()
if not project:
return None, None
settings = Settings()
language = Language(request)
confirm_digest = token_urlsafe(48)
delete_digest = token_urlsafe(48)
confirm_url = f"{settings.weburl}/comments/{project.name}/confirm/{confirm_digest}"
delete_url = f"{settings.weburl}/comments/{project.name}/delete/{delete_digest}"
confirm_url = f"{settings.system['web_url']}/comments/confirm/{confirm_digest}"
delete_url = f"{settings.system['web_url']}/comments/delete/{delete_digest}"
html_tpl = f"mail/comment_confirmation_{language.browser_language}.html"
txt_tpl = f"mail/comment_confirmation_{language.browser_language}_txt.html"
txt_what = f"Hey there. You have made a comment on {settings.system['blog_url']}. Please confirm it by " \
f"copying this link into your browser:\n{confirm_url}\nIf you want to delete your comment for,"\
f"whatever reason, please use this link:\n{delete_url}"
if not Path(f"./templates/{html_tpl}").exists():
html_tpl = f"mail/comment_confirmation_en-US.html"
if not Path(f"./templates/{txt_tpl}").exists():
html_tpl = f"mail/comment_confirmation_en-US_txt.html"
txt_what = render_template(txt_tpl,
blogurl=project.blogurl,
confirmation_url=confirm_url,
deletion_url=delete_url).replace('<pre>', "").replace('</pre>', '')
html_what = render_template(html_tpl,
blogurl=project.blogurl,
confirmation_url=confirm_url,
deletion_url=delete_url)
html_what = f"Hey there. You have made a comment on {settings.system['blog_url']}.<br>Please confirm it by " \
f"clicking on this <a href='{confirm_url}'>link</a>.<br>"\
f"In case you want to delete your comment later, please click <a href='{delete_url}'>here</a>."\
f"<br><br>If you think this is in error or someone made this comment in your name, please "\
f"write me a <a href='mailto:contact@tuxstash.de'>mail</a> to discuss options such as " \
f"blocking your mail from being used."
self.send(txt_what, html_what, email)
return confirm_digest, delete_digest
def validate(self, addr):

View File

@ -9,5 +9,3 @@
from .t_comments import TComments
from .t_location import TLocation
from .t_emails import TEmail
from .t_projects import TProjects
from .t_version import TVersion

View File

@ -16,7 +16,7 @@ class TComments(db.Model):
__table_args__ = {'useexisting': True}
# primary key
comments_id = db.Column(db.Integer, primary_key=True, autoincrement=True)
comments_id = db.Column(db.Integer, primary_key=True)
# foreign keys
location_id = db.Column(db.Integer, ForeignKey('t_location.id_location'), nullable=False)
@ -28,8 +28,7 @@ class TComments(db.Model):
is_published = db.Column(db.Boolean, nullable=False)
is_spam = db.Column(db.Boolean, nullable=False)
spam_score = db.Column(db.Float, nullable=False)
replied_to = db.Column(db.Integer, ForeignKey('t_comments.comments_id'), nullable=True, default=None)
replied_to = db.Column(db.Integer, ForeignKey('t_comments.comments_id'), nullable=True)
confirmation = db.Column(db.Text, nullable=True)
deletion = db.Column(db.Text, nullable=True)
gravatar = db.Column(db.Text, nullable=True)
project_id = db.Column(db.Integer, ForeignKey('t_projects.id_project'), nullable=False)

View File

@ -7,7 +7,6 @@
# * _license : This project is under MIT License
# *********************************************************************************/
from labertasche.database import labertasche_db as db
from sqlalchemy import ForeignKey
class TEmail(db.Model):
@ -16,7 +15,7 @@ class TEmail(db.Model):
__table_args__ = {'useexisting': True}
# primary key
id_email = db.Column(db.Integer, primary_key=True, autoincrement=True)
id_email = db.Column(db.Integer, primary_key=True)
# data
email = db.Column(db.Integer, unique=True)

View File

@ -7,7 +7,6 @@
# * _license : This project is under MIT License
# *********************************************************************************/
from labertasche.database import labertasche_db as db
from sqlalchemy import ForeignKey, UniqueConstraint
class TLocation(db.Model):
@ -16,11 +15,7 @@ class TLocation(db.Model):
__table_args__ = {'useexisting': True}
# primary key
id_location = db.Column(db.Integer, primary_key=True, autoincrement=True)
id_location = db.Column(db.Integer, primary_key=True)
# data
location = db.Column(db.Text, nullable=False)
project_id = db.Column(db.Integer, ForeignKey('t_projects.id_project'), nullable=False)
# Unique constraint
UniqueConstraint('location', 'project_id', name="unique_per_project")
location = db.Column(db.Text, nullable=False, unique=True)

View File

@ -1,30 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from labertasche.database import labertasche_db as db
class TProjects(db.Model):
# table name
__tablename__ = "t_projects"
__table_args__ = {'useexisting': True}
# primary key
id_project = db.Column(db.Integer, primary_key=True, autoincrement=True)
# data
name = db.Column(db.Text, nullable=False, unique=True)
blogurl = db.Column(db.Text, nullable=False, unique=False)
output = db.Column(db.Text, nullable=False, unique=True)
sendotp = db.Column(db.Boolean, nullable=False)
gravatar_cache = db.Column(db.Boolean, nullable=False)
gravatar_cache_dir = db.Column(db.Text, nullable=True)
gravatar_size = db.Column(db.Integer, nullable=True)
addon_smileys = db.Column(db.Boolean, nullable=False, default=True)

View File

@ -1,21 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
from labertasche.database import labertasche_db as db
class TVersion(db.Model):
# table name
__tablename__ = "t_version"
__table_args__ = {'useexisting': True}
# primary key
id_version = db.Column(db.Integer, primary_key=True, autoincrement=True)
# data
version = db.Column(db.Integer)

View File

@ -9,170 +9,22 @@
import yaml
from pathlib import Path
from platform import system
from shutil import copy
from hashlib import pbkdf2_hmac
from secrets import compare_digest
def hash_password(password, secret=None):
"""
Hashes the administrator password
:param password: The password to hash
:param secret: The site secret
:return: The hashed value as a hexadecimal string
"""
if not secret:
secret = Secret()
h = pbkdf2_hmac('sha512',
password=password.encode('utf8'),
salt=secret.encode('utf8'),
iterations=250000)
return h.hex()
class Settings:
def __init__(self):
file = Path("labertasche.yaml")
if system().lower() == "linux":
file = Path("/etc/labertasche/labertasche.yaml")
with file.open('r') as fp:
conf = yaml.safe_load(fp)
self.weburl = conf['system']['weburl']
self.cookie_domain = conf['system']['cookie_domain']
self.database_uri = conf['system']['database_uri']
self.debug = conf['system']['debug']
self.cookie_secure = conf['system']['cookie_secure']
class Secret:
def __init__(self):
file = Path(".secret")
if system().lower() == "linux":
file = Path("/etc/labertasche/.secret")
with file.open('r') as fp:
self.key = fp.readline()
class Smileys:
def __init__(self):
file = Path("smileys.yaml")
if system().lower() == "linux":
file = Path("/etc/labertasche/smileys.yaml")
with file.open('r') as fp:
conf = yaml.safe_load(fp)
self.smileys = conf['smileys']
class Credentials:
def __init__(self):
file = Path("credentials.yaml")
if system().lower() == "linux":
file = Path("/etc/labertasche/credentials.yaml")
with file.open('r') as fp:
conf = yaml.safe_load(fp)
self.username = conf['credentials']['username']
self.password = conf['credentials']['password']
def compare_password(self, userinput):
"""
Compares 2 passwords with one another
:param userinput: The input on the login page
:return: True if the passwords match, otherwise False
"""
secret = Secret()
hashed = pbkdf2_hmac('sha512',
password=userinput.encode('utf8'),
salt=secret.key.encode('utf8'),
iterations=250000)
return compare_digest(self.password, hashed.hex())
# deprecated, leave as is
class LegacySettings:
"""
Automatically loads the settings from /etc/ on Linux and same directory on other OS
"""
def __init__(self, use_backup: bool = False):
def __init__(self):
file = Path("labertasche.yaml")
if system().lower() == "linux":
file = Path("/etc/labertasche/labertasche.yaml")
# Use backup when conversion is done, this is used in db_v2.py
if use_backup:
file = file.with_suffix('.bak')
with file.open('r') as fp:
print(f"Loading old conf from {file}")
conf = yaml.safe_load(fp)
self.system = conf['system']
self.smileys = conf['smileys']
self.dashboard = conf['dashboard']
self.gravatar = conf['gravatar']
self.addons = conf['addons']
def convert_to_v2(self):
old = Path("labertasche.yaml")
if system().lower() == "linux":
old = Path("/etc/labertasche/labertasche.yaml")
systemvars = {
'system': {
'weburl': self.system['web_url'],
'cookie_domain': self.system['cookie-domain'],
'database_uri': self.system['database_uri'],
'debug': self.system['debug'],
'cookie_secure': False
}
}
credentials = {
'credentials': {
'username': self.dashboard['username'],
'password': hash_password(self.dashboard['password'], self.system['secret'])
}
}
smileys = {
'smileys': self.smileys
}
# backup old config
print("Copying old config to backup")
copy(old, old.with_suffix('.bak'))
# Write new config files
p_sys = Path('labertasche.yaml')
p_credentials = Path('credentials.yaml')
p_smileys = Path('smileys.yaml')
p_secret = Path('.secret')
if system().lower() == 'linux':
p_sys = '/etc/labertasche/' / p_sys
p_credentials = '/etc/labertasche/' / p_credentials
p_smileys = '/etc/labertasche/' / p_smileys
p_secret = '/etc/labertasche/' / p_secret
with p_sys.open('w') as fp:
print("Dumping system vars as yaml")
yaml.dump(systemvars, fp)
with p_credentials.open('w') as fp:
print("Dumping credentials as yaml")
yaml.dump(credentials, fp)
with p_smileys.open('w') as fp:
print("Dumping smileys as yaml")
yaml.dump(smileys, fp)
with p_secret.open('w') as fp:
print("Dumping secret")
fp.write(self.system['secret'])
self.smileys = conf['smileys']

1
migrations/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

45
migrations/alembic.ini Normal file
View File

@ -0,0 +1,45 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

96
migrations/env.py Normal file
View File

@ -0,0 +1,96 @@
from __future__ import with_statement
import logging
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option(
'sqlalchemy.url',
str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%'))
target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -6,74 +6,59 @@
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# *********************************************************************************/
import logging
from flask import Flask, redirect, url_for
from flask_cors import CORS
from sqlalchemy import event
# noinspection PyProtectedMember
from sqlalchemy.engine import Engine
from logging import getLogger, ERROR as LOGGING_ERROR
from flask import Flask, redirect, url_for, request
from flask_cors import CORS
from sqlalchemy import event, inspect
from labertasche.settings import Settings, Secret
from labertasche.settings import Settings
from labertasche.database import labertasche_db
from labertasche.language import Language
from labertasche.blueprints import bp_comments, bp_login, bp_dashboard, bp_jsconnector, bp_dbupgrades
from labertasche.blueprints import bp_comments, bp_login, bp_dashboard
from labertasche.helper import User
from flask_login import LoginManager
from datetime import timedelta
from flask_migrate import Migrate
# Load settings
settings = Settings()
secret = Secret()
# Flask App
laberflask = Flask(__name__)
laberflask.config.update(dict(
SESSION_COOKIE_DOMAIN=settings.cookie_domain,
SESSION_COOKIE_SECURE=settings.cookie_secure,
REMEMBER_COOKIE_SECURE=settings.cookie_secure,
REMEMBER_COOKIE_DURATION=timedelta(days=7),
REMEMBER_COOKIE_HTTPONLY=True,
REMEMBER_COOKIE_REFRESH_EACH_REQUEST=True,
DEBUG=settings.debug,
SECRET_KEY=secret.key,
TEMPLATES_AUTO_RELOAD=settings.debug,
SQLALCHEMY_DATABASE_URI=settings.database_uri,
SQLALCHEMY_TRACK_MODIFICATIONS=False,
JSON_AS_ASCII=False
SESSION_COOKIE_DOMAIN=settings.system['cookie-domain'],
DEBUG=settings.system['debug'],
SECRET_KEY=settings.system['secret'],
TEMPLATES_AUTO_RELOAD=True,
SQLALCHEMY_DATABASE_URI=settings.system['database_uri'],
SQLALCHEMY_TRACK_MODIFICATIONS=False
))
# Mark secret for deletion
del secret
# flask migrate
migrate = Migrate(laberflask, labertasche_db, render_as_batch=True)
# CORS
CORS(laberflask, resources={r"/comments": {"origins": settings.system['blog_url']}})
# Import blueprints
laberflask.register_blueprint(bp_comments)
laberflask.register_blueprint(bp_dashboard)
laberflask.register_blueprint(bp_login)
laberflask.register_blueprint(bp_jsconnector)
laberflask.register_blueprint(bp_dbupgrades)
# Disable Werkzeug's verbosity during development
log = getLogger('werkzeug')
log.setLevel(LOGGING_ERROR)
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)
# Initialize ORM
labertasche_db.init_app(laberflask)
with laberflask.app_context():
labertasche_db.create_all()
# Set up login manager
loginmgr = LoginManager(laberflask)
loginmgr.login_view = 'bp_admin_login.login'
# Initialize ORM
labertasche_db.init_app(laberflask)
with laberflask.app_context():
table_names = inspect(labertasche_db.get_engine()).get_table_names()
is_empty = table_names == []
# Only create tables if the db is empty, so we can do a controlled upgrade.
if is_empty:
labertasche_db.create_all()
# CORS
cors = CORS(laberflask, resources={r"/comments/*": {"origins": "*"}})
# There is only one user
@loginmgr.user_loader
def user_loader(user_id):
if user_id != "0":
@ -81,26 +66,14 @@ def user_loader(user_id):
return User(user_id)
# User not authorized
@loginmgr.unauthorized_handler
def login_invalid():
return redirect(url_for('bp_login.show_login'))
# Enable write-ahead-log for sqlite databases
# noinspection PyUnusedLocal
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
if settings.database_uri[0:6] == 'sqlite':
if settings.system["database_uri"][0:6] == 'sqlite':
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL;")
cursor.close()
# Inject i18n dictionaries into all templates
@laberflask.context_processor
def inject_language():
lang = Language(request)
return {"i18n": lang.i18n}

View File

@ -1,10 +1,11 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# """
# /**********************************************************************************
# * _author : Domeniko Gentner
# * _mail : code@tuxstash.de
# * _repo : https://git.tuxstash.de/gothseidank/labertasche
# * _license : This project is under MIT License
# * _repo : https://git.tuxstash.de/gothseidank/piradio
# * _license : This project is under GPL.v2
# *********************************************************************************/
from sys import path as sys_path
from os import path as os_path

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
Subproject commit 5432d0625b34ebfdc17518baed80897a82018d2d

View File

@ -0,0 +1,5 @@
/* Bulma Base */
@charset "utf-8"
@import "minireset.sass"
@import "generic.sass"

View File

@ -0,0 +1,143 @@
$body-background-color: $scheme-main !default
$body-size: 16px !default
$body-min-width: 300px !default
$body-rendering: optimizeLegibility !default
$body-family: $family-primary !default
$body-overflow-x: hidden !default
$body-overflow-y: scroll !default
$body-color: $text !default
$body-font-size: 1em !default
$body-weight: $weight-normal !default
$body-line-height: 1.5 !default
$code-family: $family-code !default
$code-padding: 0.25em 0.5em 0.25em !default
$code-weight: normal !default
$code-size: 0.875em !default
$small-font-size: 0.875em !default
$hr-background-color: $background !default
$hr-height: 2px !default
$hr-margin: 1.5rem 0 !default
$strong-color: $text-strong !default
$strong-weight: $weight-bold !default
$pre-font-size: 0.875em !default
$pre-padding: 1.25rem 1.5rem !default
$pre-code-font-size: 1em !default
html
background-color: $body-background-color
font-size: $body-size
-moz-osx-font-smoothing: grayscale
-webkit-font-smoothing: antialiased
min-width: $body-min-width
overflow-x: $body-overflow-x
overflow-y: $body-overflow-y
text-rendering: $body-rendering
text-size-adjust: 100%
article,
aside,
figure,
footer,
header,
hgroup,
section
display: block
body,
button,
input,
optgroup,
select,
textarea
font-family: $body-family
code,
pre
-moz-osx-font-smoothing: auto
-webkit-font-smoothing: auto
font-family: $code-family
body
color: $body-color
font-size: $body-font-size
font-weight: $body-weight
line-height: $body-line-height
// Inline
a
color: $link
cursor: pointer
text-decoration: none
strong
color: currentColor
&:hover
color: $link-hover
code
background-color: $code-background
color: $code
font-size: $code-size
font-weight: $code-weight
padding: $code-padding
hr
background-color: $hr-background-color
border: none
display: block
height: $hr-height
margin: $hr-margin
img
height: auto
max-width: 100%
input[type="checkbox"],
input[type="radio"]
vertical-align: baseline
small
font-size: $small-font-size
span
font-style: inherit
font-weight: inherit
strong
color: $strong-color
font-weight: $strong-weight
// Block
fieldset
border: none
pre
+overflow-touch
background-color: $pre-background
color: $pre
font-size: $pre-font-size
overflow-x: auto
padding: $pre-padding
white-space: pre
word-wrap: normal
code
background-color: transparent
color: currentColor
font-size: $pre-code-font-size
padding: 0
table
td,
th
vertical-align: top
&:not([align])
text-align: inherit
th
color: $text-strong

View File

@ -0,0 +1 @@
@warn "The helpers.sass file is DEPRECATED. It has moved into its own /helpers folder. Please import sass/helpers/_all instead."

View File

@ -0,0 +1,79 @@
/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */
// Blocks
html,
body,
p,
ol,
ul,
li,
dl,
dt,
dd,
blockquote,
figure,
fieldset,
legend,
textarea,
pre,
iframe,
hr,
h1,
h2,
h3,
h4,
h5,
h6
margin: 0
padding: 0
// Headings
h1,
h2,
h3,
h4,
h5,
h6
font-size: 100%
font-weight: normal
// List
ul
list-style: none
// Form
button,
input,
select,
textarea
margin: 0
// Box sizing
html
box-sizing: border-box
*
&,
&::before,
&::after
box-sizing: inherit
// Media
img,
video
height: auto
max-width: 100%
// Iframe
iframe
border: 0
// Table
table
border-collapse: collapse
border-spacing: 0
td,
th
padding: 0
&:not([align])
text-align: inherit

View File

@ -0,0 +1,15 @@
/* Bulma Components */
@charset "utf-8"
@import "breadcrumb.sass"
@import "card.sass"
@import "dropdown.sass"
@import "level.sass"
@import "media.sass"
@import "menu.sass"
@import "message.sass"
@import "modal.sass"
@import "navbar.sass"
@import "pagination.sass"
@import "panel.sass"
@import "tabs.sass"

View File

@ -0,0 +1,75 @@
$breadcrumb-item-color: $link !default
$breadcrumb-item-hover-color: $link-hover !default
$breadcrumb-item-active-color: $text-strong !default
$breadcrumb-item-padding-vertical: 0 !default
$breadcrumb-item-padding-horizontal: 0.75em !default
$breadcrumb-item-separator-color: $border-hover !default
.breadcrumb
@extend %block
@extend %unselectable
font-size: $size-normal
white-space: nowrap
a
align-items: center
color: $breadcrumb-item-color
display: flex
justify-content: center
padding: $breadcrumb-item-padding-vertical $breadcrumb-item-padding-horizontal
&:hover
color: $breadcrumb-item-hover-color
li
align-items: center
display: flex
&:first-child a
+ltr-property("padding", 0, false)
&.is-active
a
color: $breadcrumb-item-active-color
cursor: default
pointer-events: none
& + li::before
color: $breadcrumb-item-separator-color
content: "\0002f"
ul,
ol
align-items: flex-start
display: flex
flex-wrap: wrap
justify-content: flex-start
.icon
&:first-child
+ltr-property("margin", 0.5em)
&:last-child
+ltr-property("margin", 0.5em, false)
// Alignment
&.is-centered
ol,
ul
justify-content: center
&.is-right
ol,
ul
justify-content: flex-end
// Sizes
&.is-small
font-size: $size-small
&.is-medium
font-size: $size-medium
&.is-large
font-size: $size-large
// Styles
&.has-arrow-separator
li + li::before
content: "\02192"
&.has-bullet-separator
li + li::before
content: "\02022"
&.has-dot-separator
li + li::before
content: "\000b7"
&.has-succeeds-separator
li + li::before
content: "\0227B"

View File

@ -0,0 +1,83 @@
$card-color: $text !default
$card-background-color: $scheme-main !default
$card-shadow: 0 0.5em 1em -0.125em rgba($scheme-invert, 0.1), 0 0px 0 1px rgba($scheme-invert, 0.02) !default
$card-radius: 0.25rem !default
$card-overflow: hidden !default
$card-header-background-color: transparent !default
$card-header-color: $text-strong !default
$card-header-padding: 0.75rem 1rem !default
$card-header-shadow: 0 0.125em 0.25em rgba($scheme-invert, 0.1) !default
$card-header-weight: $weight-bold !default
$card-content-background-color: transparent !default
$card-content-padding: 1.5rem !default
$card-footer-background-color: transparent !default
$card-footer-border-top: 1px solid $border-light !default
$card-footer-padding: 0.75rem !default
$card-media-margin: $block-spacing !default
.card
background-color: $card-background-color
border-radius: $card-radius
box-shadow: $card-shadow
color: $card-color
max-width: 100%
overflow: $card-overflow
position: relative
.card-header
background-color: $card-header-background-color
align-items: stretch
box-shadow: $card-header-shadow
display: flex
.card-header-title
align-items: center
color: $card-header-color
display: flex
flex-grow: 1
font-weight: $card-header-weight
padding: $card-header-padding
&.is-centered
justify-content: center
.card-header-icon
align-items: center
cursor: pointer
display: flex
justify-content: center
padding: $card-header-padding
.card-image
display: block
position: relative
.card-content
background-color: $card-content-background-color
padding: $card-content-padding
.card-footer
background-color: $card-footer-background-color
border-top: $card-footer-border-top
align-items: stretch
display: flex
.card-footer-item
align-items: center
display: flex
flex-basis: 0
flex-grow: 1
flex-shrink: 0
justify-content: center
padding: $card-footer-padding
&:not(:last-child)
+ltr-property("border", $card-footer-border-top)
// Combinations
.card
.media:not(:last-child)
margin-bottom: $card-media-margin

View File

@ -0,0 +1,81 @@
$dropdown-menu-min-width: 12rem !default
$dropdown-content-background-color: $scheme-main !default
$dropdown-content-arrow: $link !default
$dropdown-content-offset: 4px !default
$dropdown-content-padding-bottom: 0.5rem !default
$dropdown-content-padding-top: 0.5rem !default
$dropdown-content-radius: $radius !default
$dropdown-content-shadow: 0 0.5em 1em -0.125em rgba($scheme-invert, 0.1), 0 0px 0 1px rgba($scheme-invert, 0.02) !default
$dropdown-content-z: 20 !default
$dropdown-item-color: $text !default
$dropdown-item-hover-color: $scheme-invert !default
$dropdown-item-hover-background-color: $background !default
$dropdown-item-active-color: $link-invert !default
$dropdown-item-active-background-color: $link !default
$dropdown-divider-background-color: $border-light !default
.dropdown
display: inline-flex
position: relative
vertical-align: top
&.is-active,
&.is-hoverable:hover
.dropdown-menu
display: block
&.is-right
.dropdown-menu
left: auto
right: 0
&.is-up
.dropdown-menu
bottom: 100%
padding-bottom: $dropdown-content-offset
padding-top: initial
top: auto
.dropdown-menu
display: none
+ltr-position(0, false)
min-width: $dropdown-menu-min-width
padding-top: $dropdown-content-offset
position: absolute
top: 100%
z-index: $dropdown-content-z
.dropdown-content
background-color: $dropdown-content-background-color
border-radius: $dropdown-content-radius
box-shadow: $dropdown-content-shadow
padding-bottom: $dropdown-content-padding-bottom
padding-top: $dropdown-content-padding-top
.dropdown-item
color: $dropdown-item-color
display: block
font-size: 0.875rem
line-height: 1.5
padding: 0.375rem 1rem
position: relative
a.dropdown-item,
button.dropdown-item
+ltr-property("padding", 3rem)
text-align: inherit
white-space: nowrap
width: 100%
&:hover
background-color: $dropdown-item-hover-background-color
color: $dropdown-item-hover-color
&.is-active
background-color: $dropdown-item-active-background-color
color: $dropdown-item-active-color
.dropdown-divider
background-color: $dropdown-divider-background-color
border: none
display: block
height: 1px
margin: 0.5rem 0

View File

@ -0,0 +1,77 @@
$level-item-spacing: ($block-spacing / 2) !default
.level
@extend %block
align-items: center
justify-content: space-between
code
border-radius: $radius
img
display: inline-block
vertical-align: top
// Modifiers
&.is-mobile
display: flex
.level-left,
.level-right
display: flex
.level-left + .level-right
margin-top: 0
.level-item
&:not(:last-child)
margin-bottom: 0
+ltr-property("margin", $level-item-spacing)
&:not(.is-narrow)
flex-grow: 1
// Responsiveness
+tablet
display: flex
& > .level-item
&:not(.is-narrow)
flex-grow: 1
.level-item
align-items: center
display: flex
flex-basis: auto
flex-grow: 0
flex-shrink: 0
justify-content: center
.title,
.subtitle
margin-bottom: 0
// Responsiveness
+mobile
&:not(:last-child)
margin-bottom: $level-item-spacing
.level-left,
.level-right
flex-basis: auto
flex-grow: 0
flex-shrink: 0
.level-item
// Modifiers
&.is-flexible
flex-grow: 1
// Responsiveness
+tablet
&:not(:last-child)
+ltr-property("margin", $level-item-spacing)
.level-left
align-items: center
justify-content: flex-start
// Responsiveness
+mobile
& + .level-right
margin-top: 1.5rem
+tablet
display: flex
.level-right
align-items: center
justify-content: flex-end
// Responsiveness
+tablet
display: flex

View File

@ -0,0 +1,52 @@
$media-border-color: bulmaRgba($border, 0.5) !default
$media-spacing: 1rem
$media-spacing-large: 1.5rem
.media
align-items: flex-start
display: flex
text-align: inherit
.content:not(:last-child)
margin-bottom: 0.75rem
.media
border-top: 1px solid $media-border-color
display: flex
padding-top: 0.75rem
.content:not(:last-child),
.control:not(:last-child)
margin-bottom: 0.5rem
.media
padding-top: 0.5rem
& + .media
margin-top: 0.5rem
& + .media
border-top: 1px solid $media-border-color
margin-top: $media-spacing
padding-top: $media-spacing
// Sizes
&.is-large
& + .media
margin-top: $media-spacing-large
padding-top: $media-spacing-large
.media-left,
.media-right
flex-basis: auto
flex-grow: 0
flex-shrink: 0
.media-left
+ltr-property("margin", $media-spacing)
.media-right
+ltr-property("margin", $media-spacing, false)
.media-content
flex-basis: auto
flex-grow: 1
flex-shrink: 1
text-align: inherit
+mobile
.media-content
overflow-x: auto

View File

@ -0,0 +1,57 @@
$menu-item-color: $text !default
$menu-item-radius: $radius-small !default
$menu-item-hover-color: $text-strong !default
$menu-item-hover-background-color: $background !default
$menu-item-active-color: $link-invert !default
$menu-item-active-background-color: $link !default
$menu-list-border-left: 1px solid $border !default
$menu-list-line-height: 1.25 !default
$menu-list-link-padding: 0.5em 0.75em !default
$menu-nested-list-margin: 0.75em !default
$menu-nested-list-padding-left: 0.75em !default
$menu-label-color: $text-light !default
$menu-label-font-size: 0.75em !default
$menu-label-letter-spacing: 0.1em !default
$menu-label-spacing: 1em !default
.menu
font-size: $size-normal
// Sizes
&.is-small
font-size: $size-small
&.is-medium
font-size: $size-medium
&.is-large
font-size: $size-large
.menu-list
line-height: $menu-list-line-height
a
border-radius: $menu-item-radius
color: $menu-item-color
display: block
padding: $menu-list-link-padding
&:hover
background-color: $menu-item-hover-background-color
color: $menu-item-hover-color
// Modifiers
&.is-active
background-color: $menu-item-active-background-color
color: $menu-item-active-color
li
ul
+ltr-property("border", $menu-list-border-left, false)
margin: $menu-nested-list-margin
+ltr-property("padding", $menu-nested-list-padding-left, false)
.menu-label
color: $menu-label-color
font-size: $menu-label-font-size
letter-spacing: $menu-label-letter-spacing
text-transform: uppercase
&:not(:first-child)
margin-top: $menu-label-spacing
&:not(:last-child)
margin-bottom: $menu-label-spacing

View File

@ -0,0 +1,99 @@
$message-background-color: $background !default
$message-radius: $radius !default
$message-header-background-color: $text !default
$message-header-color: $text-invert !default
$message-header-weight: $weight-bold !default
$message-header-padding: 0.75em 1em !default
$message-header-radius: $radius !default
$message-body-border-color: $border !default
$message-body-border-width: 0 0 0 4px !default
$message-body-color: $text !default
$message-body-padding: 1.25em 1.5em !default
$message-body-radius: $radius !default
$message-body-pre-background-color: $scheme-main !default
$message-body-pre-code-background-color: transparent !default
$message-header-body-border-width: 0 !default
$message-colors: $colors !default
.message
@extend %block
background-color: $message-background-color
border-radius: $message-radius
font-size: $size-normal
strong
color: currentColor
a:not(.button):not(.tag):not(.dropdown-item)
color: currentColor
text-decoration: underline
// Sizes
&.is-small
font-size: $size-small
&.is-medium
font-size: $size-medium
&.is-large
font-size: $size-large
// Colors
@each $name, $components in $message-colors
$color: nth($components, 1)
$color-invert: nth($components, 2)
$color-light: null
$color-dark: null
@if length($components) >= 3
$color-light: nth($components, 3)
@if length($components) >= 4
$color-dark: nth($components, 4)
@else
$color-luminance: colorLuminance($color)
$darken-percentage: $color-luminance * 70%
$desaturate-percentage: $color-luminance * 30%
$color-dark: desaturate(darken($color, $darken-percentage), $desaturate-percentage)
@else
$color-lightning: max((100% - lightness($color)) - 2%, 0%)
$color-light: lighten($color, $color-lightning)
&.is-#{$name}
background-color: $color-light
.message-header
background-color: $color
color: $color-invert
.message-body
border-color: $color
color: $color-dark
.message-header
align-items: center
background-color: $message-header-background-color
border-radius: $message-header-radius $message-header-radius 0 0
color: $message-header-color
display: flex
font-weight: $message-header-weight
justify-content: space-between
line-height: 1.25
padding: $message-header-padding
position: relative
.delete
flex-grow: 0
flex-shrink: 0
+ltr-property("margin", 0.75em, false)
& + .message-body
border-width: $message-header-body-border-width
border-top-left-radius: 0
border-top-right-radius: 0
.message-body
border-color: $message-body-border-color
border-radius: $message-body-radius
border-style: solid
border-width: $message-body-border-width
color: $message-body-color
padding: $message-body-padding
code,
pre
background-color: $message-body-pre-background-color
pre code
background-color: $message-body-pre-code-background-color

View File

@ -0,0 +1,115 @@
$modal-z: 40 !default
$modal-background-background-color: bulmaRgba($scheme-invert, 0.86) !default
$modal-content-width: 640px !default
$modal-content-margin-mobile: 20px !default
$modal-content-spacing-mobile: 160px !default
$modal-content-spacing-tablet: 40px !default
$modal-close-dimensions: 40px !default
$modal-close-right: 20px !default
$modal-close-top: 20px !default
$modal-card-spacing: 40px !default
$modal-card-head-background-color: $background !default
$modal-card-head-border-bottom: 1px solid $border !default
$modal-card-head-padding: 20px !default
$modal-card-head-radius: $radius-large !default
$modal-card-title-color: $text-strong !default
$modal-card-title-line-height: 1 !default
$modal-card-title-size: $size-4 !default
$modal-card-foot-radius: $radius-large !default
$modal-card-foot-border-top: 1px solid $border !default
$modal-card-body-background-color: $scheme-main !default
$modal-card-body-padding: 20px !default
$modal-breakpoint: $tablet !default
.modal
@extend %overlay
align-items: center
display: none
flex-direction: column
justify-content: center
overflow: hidden
position: fixed
z-index: $modal-z
// Modifiers
&.is-active
display: flex
.modal-background
@extend %overlay
background-color: $modal-background-background-color
.modal-content,
.modal-card
margin: 0 $modal-content-margin-mobile
max-height: calc(100vh - #{$modal-content-spacing-mobile})
overflow: auto
position: relative
width: 100%
// Responsiveness
+from($modal-breakpoint)
margin: 0 auto
max-height: calc(100vh - #{$modal-content-spacing-tablet})
width: $modal-content-width
.modal-close
@extend %delete
background: none
height: $modal-close-dimensions
position: fixed
+ltr-position($modal-close-right)
top: $modal-close-top
width: $modal-close-dimensions
.modal-card
display: flex
flex-direction: column
max-height: calc(100vh - #{$modal-card-spacing})
overflow: hidden
-ms-overflow-y: visible
.modal-card-head,
.modal-card-foot
align-items: center
background-color: $modal-card-head-background-color
display: flex
flex-shrink: 0
justify-content: flex-start
padding: $modal-card-head-padding
position: relative
.modal-card-head
border-bottom: $modal-card-head-border-bottom
border-top-left-radius: $modal-card-head-radius
border-top-right-radius: $modal-card-head-radius
.modal-card-title
color: $modal-card-title-color
flex-grow: 1
flex-shrink: 0
font-size: $modal-card-title-size
line-height: $modal-card-title-line-height
.modal-card-foot
border-bottom-left-radius: $modal-card-foot-radius
border-bottom-right-radius: $modal-card-foot-radius
border-top: $modal-card-foot-border-top
.button
&:not(:last-child)
+ltr-property("margin", 0.5em)
.modal-card-body
+overflow-touch
background-color: $modal-card-body-background-color
flex-grow: 1
flex-shrink: 1
overflow: auto
padding: $modal-card-body-padding

View File

@ -0,0 +1,443 @@
$navbar-background-color: $scheme-main !default
$navbar-box-shadow-size: 0 2px 0 0 !default
$navbar-box-shadow-color: $background !default
$navbar-height: 3.25rem !default
$navbar-padding-vertical: 1rem !default
$navbar-padding-horizontal: 2rem !default
$navbar-z: 30 !default
$navbar-fixed-z: 30 !default
$navbar-item-color: $text !default
$navbar-item-hover-color: $link !default
$navbar-item-hover-background-color: $scheme-main-bis !default
$navbar-item-active-color: $scheme-invert !default
$navbar-item-active-background-color: transparent !default
$navbar-item-img-max-height: 1.75rem !default
$navbar-burger-color: $navbar-item-color !default
$navbar-tab-hover-background-color: transparent !default
$navbar-tab-hover-border-bottom-color: $link !default
$navbar-tab-active-color: $link !default
$navbar-tab-active-background-color: transparent !default
$navbar-tab-active-border-bottom-color: $link !default
$navbar-tab-active-border-bottom-style: solid !default
$navbar-tab-active-border-bottom-width: 3px !default
$navbar-dropdown-background-color: $scheme-main !default
$navbar-dropdown-border-top: 2px solid $border !default
$navbar-dropdown-offset: -4px !default
$navbar-dropdown-arrow: $link !default
$navbar-dropdown-radius: $radius-large !default
$navbar-dropdown-z: 20 !default
$navbar-dropdown-boxed-radius: $radius-large !default
$navbar-dropdown-boxed-shadow: 0 8px 8px bulmaRgba($scheme-invert, 0.1), 0 0 0 1px bulmaRgba($scheme-invert, 0.1) !default
$navbar-dropdown-item-hover-color: $scheme-invert !default
$navbar-dropdown-item-hover-background-color: $background !default
$navbar-dropdown-item-active-color: $link !default
$navbar-dropdown-item-active-background-color: $background !default
$navbar-divider-background-color: $background !default
$navbar-divider-height: 2px !default
$navbar-bottom-box-shadow-size: 0 -2px 0 0 !default
$navbar-breakpoint: $desktop !default
$navbar-colors: $colors !default
=navbar-fixed
left: 0
position: fixed
right: 0
z-index: $navbar-fixed-z
.navbar
background-color: $navbar-background-color
min-height: $navbar-height
position: relative
z-index: $navbar-z
@each $name, $pair in $navbar-colors
$color: nth($pair, 1)
$color-invert: nth($pair, 2)
&.is-#{$name}
background-color: $color
color: $color-invert
.navbar-brand
& > .navbar-item,
.navbar-link
color: $color-invert
& > a.navbar-item,
.navbar-link
&:focus,
&:hover,
&.is-active
background-color: bulmaDarken($color, 5%)
color: $color-invert
.navbar-link
&::after
border-color: $color-invert
.navbar-burger
color: $color-invert
+from($navbar-breakpoint)
.navbar-start,
.navbar-end
& > .navbar-item,
.navbar-link
color: $color-invert
& > a.navbar-item,
.navbar-link
&:focus,
&:hover,
&.is-active
background-color: bulmaDarken($color, 5%)
color: $color-invert
.navbar-link
&::after
border-color: $color-invert
.navbar-item.has-dropdown:focus .navbar-link,
.navbar-item.has-dropdown:hover .navbar-link,
.navbar-item.has-dropdown.is-active .navbar-link
background-color: bulmaDarken($color, 5%)
color: $color-invert
.navbar-dropdown
a.navbar-item
&.is-active
background-color: $color
color: $color-invert
& > .container
align-items: stretch
display: flex
min-height: $navbar-height
width: 100%
&.has-shadow
box-shadow: $navbar-box-shadow-size $navbar-box-shadow-color
&.is-fixed-bottom,
&.is-fixed-top
+navbar-fixed
&.is-fixed-bottom
bottom: 0
&.has-shadow
box-shadow: $navbar-bottom-box-shadow-size $navbar-box-shadow-color
&.is-fixed-top
top: 0
html,
body
&.has-navbar-fixed-top
padding-top: $navbar-height
&.has-navbar-fixed-bottom
padding-bottom: $navbar-height
.navbar-brand,
.navbar-tabs
align-items: stretch
display: flex
flex-shrink: 0
min-height: $navbar-height
.navbar-brand
a.navbar-item
&:focus,
&:hover
background-color: transparent
.navbar-tabs
+overflow-touch
max-width: 100vw
overflow-x: auto
overflow-y: hidden
.navbar-burger
color: $navbar-burger-color
+hamburger($navbar-height)
+ltr-property("margin", auto, false)
.navbar-menu
display: none
.navbar-item,
.navbar-link
color: $navbar-item-color
display: block
line-height: 1.5
padding: 0.5rem 0.75rem
position: relative
.icon
&:only-child
margin-left: -0.25rem
margin-right: -0.25rem
a.navbar-item,
.navbar-link
cursor: pointer
&:focus,
&:focus-within,
&:hover,
&.is-active
background-color: $navbar-item-hover-background-color
color: $navbar-item-hover-color
.navbar-item
flex-grow: 0
flex-shrink: 0
img
max-height: $navbar-item-img-max-height
&.has-dropdown
padding: 0
&.is-expanded
flex-grow: 1
flex-shrink: 1
&.is-tab
border-bottom: 1px solid transparent
min-height: $navbar-height
padding-bottom: calc(0.5rem - 1px)
&:focus,
&:hover
background-color: $navbar-tab-hover-background-color
border-bottom-color: $navbar-tab-hover-border-bottom-color
&.is-active
background-color: $navbar-tab-active-background-color
border-bottom-color: $navbar-tab-active-border-bottom-color
border-bottom-style: $navbar-tab-active-border-bottom-style
border-bottom-width: $navbar-tab-active-border-bottom-width
color: $navbar-tab-active-color
padding-bottom: calc(0.5rem - #{$navbar-tab-active-border-bottom-width})
.navbar-content
flex-grow: 1
flex-shrink: 1
.navbar-link:not(.is-arrowless)
+ltr-property("padding", 2.5em)
&::after
@extend %arrow
border-color: $navbar-dropdown-arrow
margin-top: -0.375em
+ltr-position(1.125em)
.navbar-dropdown
font-size: 0.875rem
padding-bottom: 0.5rem
padding-top: 0.5rem
.navbar-item
padding-left: 1.5rem
padding-right: 1.5rem
.navbar-divider
background-color: $navbar-divider-background-color
border: none
display: none
height: $navbar-divider-height
margin: 0.5rem 0
+until($navbar-breakpoint)
.navbar > .container
display: block
.navbar-brand,
.navbar-tabs
.navbar-item
align-items: center
display: flex
.navbar-link
&::after
display: none
.navbar-menu
background-color: $navbar-background-color
box-shadow: 0 8px 16px bulmaRgba($scheme-invert, 0.1)
padding: 0.5rem 0
&.is-active
display: block
// Fixed navbar
.navbar
&.is-fixed-bottom-touch,
&.is-fixed-top-touch
+navbar-fixed
&.is-fixed-bottom-touch
bottom: 0
&.has-shadow
box-shadow: 0 -2px 3px bulmaRgba($scheme-invert, 0.1)
&.is-fixed-top-touch
top: 0
&.is-fixed-top,
&.is-fixed-top-touch
.navbar-menu
+overflow-touch
max-height: calc(100vh - #{$navbar-height})
overflow: auto
html,
body
&.has-navbar-fixed-top-touch
padding-top: $navbar-height
&.has-navbar-fixed-bottom-touch
padding-bottom: $navbar-height
+from($navbar-breakpoint)
.navbar,
.navbar-menu,
.navbar-start,
.navbar-end
align-items: stretch
display: flex
.navbar
min-height: $navbar-height
&.is-spaced
padding: $navbar-padding-vertical $navbar-padding-horizontal
.navbar-start,
.navbar-end
align-items: center
a.navbar-item,
.navbar-link
border-radius: $radius
&.is-transparent
a.navbar-item,
.navbar-link
&:focus,
&:hover,
&.is-active
background-color: transparent !important
.navbar-item.has-dropdown
&.is-active,
&.is-hoverable:focus,
&.is-hoverable:focus-within,
&.is-hoverable:hover
.navbar-link
background-color: transparent !important
.navbar-dropdown
a.navbar-item
&:focus,
&:hover
background-color: $navbar-dropdown-item-hover-background-color
color: $navbar-dropdown-item-hover-color
&.is-active
background-color: $navbar-dropdown-item-active-background-color
color: $navbar-dropdown-item-active-color
.navbar-burger
display: none
.navbar-item,
.navbar-link
align-items: center
display: flex
.navbar-item
&.has-dropdown
align-items: stretch
&.has-dropdown-up
.navbar-link::after
transform: rotate(135deg) translate(0.25em, -0.25em)
.navbar-dropdown
border-bottom: $navbar-dropdown-border-top
border-radius: $navbar-dropdown-radius $navbar-dropdown-radius 0 0
border-top: none
bottom: 100%
box-shadow: 0 -8px 8px bulmaRgba($scheme-invert, 0.1)
top: auto
&.is-active,
&.is-hoverable:focus,
&.is-hoverable:focus-within,
&.is-hoverable:hover
.navbar-dropdown
display: block
.navbar.is-spaced &,
&.is-boxed
opacity: 1
pointer-events: auto
transform: translateY(0)
.navbar-menu
flex-grow: 1
flex-shrink: 0
.navbar-start
justify-content: flex-start
+ltr-property("margin", auto)
.navbar-end
justify-content: flex-end
+ltr-property("margin", auto, false)
.navbar-dropdown
background-color: $navbar-dropdown-background-color
border-bottom-left-radius: $navbar-dropdown-radius
border-bottom-right-radius: $navbar-dropdown-radius
border-top: $navbar-dropdown-border-top
box-shadow: 0 8px 8px bulmaRgba($scheme-invert, 0.1)
display: none
font-size: 0.875rem
+ltr-position(0, false)
min-width: 100%
position: absolute
top: 100%
z-index: $navbar-dropdown-z
.navbar-item
padding: 0.375rem 1rem
white-space: nowrap
a.navbar-item
+ltr-property("padding", 3rem)
&:focus,
&:hover
background-color: $navbar-dropdown-item-hover-background-color
color: $navbar-dropdown-item-hover-color
&.is-active
background-color: $navbar-dropdown-item-active-background-color
color: $navbar-dropdown-item-active-color
.navbar.is-spaced &,
&.is-boxed
border-radius: $navbar-dropdown-boxed-radius
border-top: none
box-shadow: $navbar-dropdown-boxed-shadow
display: block
opacity: 0
pointer-events: none
top: calc(100% + (#{$navbar-dropdown-offset}))
transform: translateY(-5px)
transition-duration: $speed
transition-property: opacity, transform
&.is-right
left: auto
right: 0
.navbar-divider
display: block
.navbar > .container,
.container > .navbar
.navbar-brand
+ltr-property("margin", -.75rem, false)
.navbar-menu
+ltr-property("margin", -.75rem)
// Fixed navbar
.navbar
&.is-fixed-bottom-desktop,
&.is-fixed-top-desktop
+navbar-fixed
&.is-fixed-bottom-desktop
bottom: 0
&.has-shadow
box-shadow: 0 -2px 3px bulmaRgba($scheme-invert, 0.1)
&.is-fixed-top-desktop
top: 0
html,
body
&.has-navbar-fixed-top-desktop
padding-top: $navbar-height
&.has-navbar-fixed-bottom-desktop
padding-bottom: $navbar-height
&.has-spaced-navbar-fixed-top
padding-top: $navbar-height + ($navbar-padding-vertical * 2)
&.has-spaced-navbar-fixed-bottom
padding-bottom: $navbar-height + ($navbar-padding-vertical * 2)
// Hover/Active states
a.navbar-item,
.navbar-link
&.is-active
color: $navbar-item-active-color
&.is-active:not(:focus):not(:hover)
background-color: $navbar-item-active-background-color
.navbar-item.has-dropdown
&:focus,
&:hover,
&.is-active
.navbar-link
background-color: $navbar-item-hover-background-color
// Combination
.hero
&.is-fullheight-with-navbar
min-height: calc(100vh - #{$navbar-height})

View File

@ -0,0 +1,150 @@
$pagination-color: $text-strong !default
$pagination-border-color: $border !default
$pagination-margin: -0.25rem !default
$pagination-min-width: $control-height !default
$pagination-item-font-size: 1em !default
$pagination-item-margin: 0.25rem !default
$pagination-item-padding-left: 0.5em !default
$pagination-item-padding-right: 0.5em !default
$pagination-hover-color: $link-hover !default
$pagination-hover-border-color: $link-hover-border !default
$pagination-focus-color: $link-focus !default
$pagination-focus-border-color: $link-focus-border !default
$pagination-active-color: $link-active !default
$pagination-active-border-color: $link-active-border !default
$pagination-disabled-color: $text-light !default
$pagination-disabled-background-color: $border !default
$pagination-disabled-border-color: $border !default
$pagination-current-color: $link-invert !default
$pagination-current-background-color: $link !default
$pagination-current-border-color: $link !default
$pagination-ellipsis-color: $grey-light !default
$pagination-shadow-inset: inset 0 1px 2px rgba($scheme-invert, 0.2)
.pagination
@extend %block
font-size: $size-normal
margin: $pagination-margin
// Sizes
&.is-small
font-size: $size-small
&.is-medium
font-size: $size-medium
&.is-large
font-size: $size-large
&.is-rounded
.pagination-previous,
.pagination-next
padding-left: 1em
padding-right: 1em
border-radius: $radius-rounded
.pagination-link
border-radius: $radius-rounded
.pagination,
.pagination-list
align-items: center
display: flex
justify-content: center
text-align: center
.pagination-previous,
.pagination-next,
.pagination-link,
.pagination-ellipsis
@extend %control
@extend %unselectable
font-size: $pagination-item-font-size
justify-content: center
margin: $pagination-item-margin
padding-left: $pagination-item-padding-left
padding-right: $pagination-item-padding-right
text-align: center
.pagination-previous,
.pagination-next,
.pagination-link
border-color: $pagination-border-color
color: $pagination-color
min-width: $pagination-min-width
&:hover
border-color: $pagination-hover-border-color
color: $pagination-hover-color
&:focus
border-color: $pagination-focus-border-color
&:active
box-shadow: $pagination-shadow-inset
&[disabled]
background-color: $pagination-disabled-background-color
border-color: $pagination-disabled-border-color
box-shadow: none
color: $pagination-disabled-color
opacity: 0.5
.pagination-previous,
.pagination-next
padding-left: 0.75em
padding-right: 0.75em
white-space: nowrap
.pagination-link
&.is-current
background-color: $pagination-current-background-color
border-color: $pagination-current-border-color
color: $pagination-current-color
.pagination-ellipsis
color: $pagination-ellipsis-color
pointer-events: none
.pagination-list
flex-wrap: wrap
+mobile
.pagination
flex-wrap: wrap
.pagination-previous,
.pagination-next
flex-grow: 1
flex-shrink: 1
.pagination-list
li
flex-grow: 1
flex-shrink: 1
+tablet
.pagination-list
flex-grow: 1
flex-shrink: 1
justify-content: flex-start
order: 1
.pagination-previous
order: 2
.pagination-next
order: 3
.pagination
justify-content: space-between
&.is-centered
.pagination-previous
order: 1
.pagination-list
justify-content: center
order: 2
.pagination-next
order: 3
&.is-right
.pagination-previous
order: 1
.pagination-next
order: 2
.pagination-list
justify-content: flex-end
order: 3

View File

@ -0,0 +1,119 @@
$panel-margin: $block-spacing !default
$panel-item-border: 1px solid $border-light !default
$panel-radius: $radius-large !default
$panel-shadow: 0 0.5em 1em -0.125em rgba($scheme-invert, 0.1), 0 0px 0 1px rgba($scheme-invert, 0.02) !default
$panel-heading-background-color: $border-light !default
$panel-heading-color: $text-strong !default
$panel-heading-line-height: 1.25 !default
$panel-heading-padding: 0.75em 1em !default
$panel-heading-radius: $radius !default
$panel-heading-size: 1.25em !default
$panel-heading-weight: $weight-bold !default
$panel-tabs-font-size: 0.875em !default
$panel-tab-border-bottom: 1px solid $border !default
$panel-tab-active-border-bottom-color: $link-active-border !default
$panel-tab-active-color: $link-active !default
$panel-list-item-color: $text !default
$panel-list-item-hover-color: $link !default
$panel-block-color: $text-strong !default
$panel-block-hover-background-color: $background !default
$panel-block-active-border-left-color: $link !default
$panel-block-active-color: $link-active !default
$panel-block-active-icon-color: $link !default
$panel-icon-color: $text-light !default
$panel-colors: $colors !default
.panel
border-radius: $panel-radius
box-shadow: $panel-shadow
font-size: $size-normal
&:not(:last-child)
margin-bottom: $panel-margin
// Colors
@each $name, $components in $panel-colors
$color: nth($components, 1)
$color-invert: nth($components, 2)
&.is-#{$name}
.panel-heading
background-color: $color
color: $color-invert
.panel-tabs a.is-active
border-bottom-color: $color
.panel-block.is-active .panel-icon
color: $color
.panel-tabs,
.panel-block
&:not(:last-child)
border-bottom: $panel-item-border
.panel-heading
background-color: $panel-heading-background-color
border-radius: $panel-radius $panel-radius 0 0
color: $panel-heading-color
font-size: $panel-heading-size
font-weight: $panel-heading-weight
line-height: $panel-heading-line-height
padding: $panel-heading-padding
.panel-tabs
align-items: flex-end
display: flex
font-size: $panel-tabs-font-size
justify-content: center
a
border-bottom: $panel-tab-border-bottom
margin-bottom: -1px
padding: 0.5em
// Modifiers
&.is-active
border-bottom-color: $panel-tab-active-border-bottom-color
color: $panel-tab-active-color
.panel-list
a
color: $panel-list-item-color
&:hover
color: $panel-list-item-hover-color
.panel-block
align-items: center
color: $panel-block-color
display: flex
justify-content: flex-start
padding: 0.5em 0.75em
input[type="checkbox"]
+ltr-property("margin", 0.75em)
& > .control
flex-grow: 1
flex-shrink: 1
width: 100%
&.is-wrapped
flex-wrap: wrap
&.is-active
border-left-color: $panel-block-active-border-left-color
color: $panel-block-active-color
.panel-icon
color: $panel-block-active-icon-color
&:last-child
border-bottom-left-radius: $panel-radius
border-bottom-right-radius: $panel-radius
a.panel-block,
label.panel-block
cursor: pointer
&:hover
background-color: $panel-block-hover-background-color
.panel-icon
+fa(14px, 1em)
color: $panel-icon-color
+ltr-property("margin", 0.75em)
.fa
font-size: inherit
line-height: inherit

View File

@ -0,0 +1,174 @@
$tabs-border-bottom-color: $border !default
$tabs-border-bottom-style: solid !default
$tabs-border-bottom-width: 1px !default
$tabs-link-color: $text !default
$tabs-link-hover-border-bottom-color: $text-strong !default
$tabs-link-hover-color: $text-strong !default
$tabs-link-active-border-bottom-color: $link !default
$tabs-link-active-color: $link !default
$tabs-link-padding: 0.5em 1em !default
$tabs-boxed-link-radius: $radius !default
$tabs-boxed-link-hover-background-color: $background !default
$tabs-boxed-link-hover-border-bottom-color: $border !default
$tabs-boxed-link-active-background-color: $scheme-main !default
$tabs-boxed-link-active-border-color: $border !default
$tabs-boxed-link-active-border-bottom-color: transparent !default
$tabs-toggle-link-border-color: $border !default
$tabs-toggle-link-border-style: solid !default
$tabs-toggle-link-border-width: 1px !default
$tabs-toggle-link-hover-background-color: $background !default
$tabs-toggle-link-hover-border-color: $border-hover !default
$tabs-toggle-link-radius: $radius !default
$tabs-toggle-link-active-background-color: $link !default
$tabs-toggle-link-active-border-color: $link !default
$tabs-toggle-link-active-color: $link-invert !default
.tabs
@extend %block
+overflow-touch
@extend %unselectable
align-items: stretch
display: flex
font-size: $size-normal
justify-content: space-between
overflow: hidden
overflow-x: auto
white-space: nowrap
a
align-items: center
border-bottom-color: $tabs-border-bottom-color
border-bottom-style: $tabs-border-bottom-style
border-bottom-width: $tabs-border-bottom-width
color: $tabs-link-color
display: flex
justify-content: center
margin-bottom: -#{$tabs-border-bottom-width}
padding: $tabs-link-padding
vertical-align: top
&:hover
border-bottom-color: $tabs-link-hover-border-bottom-color
color: $tabs-link-hover-color
li
display: block
&.is-active
a
border-bottom-color: $tabs-link-active-border-bottom-color
color: $tabs-link-active-color
ul
align-items: center
border-bottom-color: $tabs-border-bottom-color
border-bottom-style: $tabs-border-bottom-style
border-bottom-width: $tabs-border-bottom-width
display: flex
flex-grow: 1
flex-shrink: 0
justify-content: flex-start
&.is-left
padding-right: 0.75em
&.is-center
flex: none
justify-content: center
padding-left: 0.75em
padding-right: 0.75em
&.is-right
justify-content: flex-end
padding-left: 0.75em
.icon
&:first-child
+ltr-property("margin", 0.5em)
&:last-child
+ltr-property("margin", 0.5em, false)
// Alignment
&.is-centered
ul
justify-content: center
&.is-right
ul
justify-content: flex-end
// Styles
&.is-boxed
a
border: 1px solid transparent
+ltr
border-radius: $tabs-boxed-link-radius $tabs-boxed-link-radius 0 0
+rtl
border-radius: 0 0 $tabs-boxed-link-radius $tabs-boxed-link-radius
&:hover
background-color: $tabs-boxed-link-hover-background-color
border-bottom-color: $tabs-boxed-link-hover-border-bottom-color
li
&.is-active
a
background-color: $tabs-boxed-link-active-background-color
border-color: $tabs-boxed-link-active-border-color
border-bottom-color: $tabs-boxed-link-active-border-bottom-color !important
&.is-fullwidth
li
flex-grow: 1
flex-shrink: 0
&.is-toggle
a
border-color: $tabs-toggle-link-border-color
border-style: $tabs-toggle-link-border-style
border-width: $tabs-toggle-link-border-width
margin-bottom: 0
position: relative
&:hover
background-color: $tabs-toggle-link-hover-background-color
border-color: $tabs-toggle-link-hover-border-color
z-index: 2
li
& + li
+ltr-property("margin", -#{$tabs-toggle-link-border-width}, false)
&:first-child a
+ltr
border-top-left-radius: $tabs-toggle-link-radius
border-bottom-left-radius: $tabs-toggle-link-radius
+rtl
border-top-right-radius: $tabs-toggle-link-radius
border-bottom-right-radius: $tabs-toggle-link-radius
&:last-child a
+ltr
border-top-right-radius: $tabs-toggle-link-radius
border-bottom-right-radius: $tabs-toggle-link-radius
+rtl
border-top-left-radius: $tabs-toggle-link-radius
border-bottom-left-radius: $tabs-toggle-link-radius
&.is-active
a
background-color: $tabs-toggle-link-active-background-color
border-color: $tabs-toggle-link-active-border-color
color: $tabs-toggle-link-active-color
z-index: 1
ul
border-bottom: none
&.is-toggle-rounded
li
&:first-child a
+ltr
border-bottom-left-radius: $radius-rounded
border-top-left-radius: $radius-rounded
padding-left: 1.25em
+rtl
border-bottom-right-radius: $radius-rounded
border-top-right-radius: $radius-rounded
padding-right: 1.25em
&:last-child a
+ltr
border-bottom-right-radius: $radius-rounded
border-top-right-radius: $radius-rounded
padding-right: 1.25em
+rtl
border-bottom-left-radius: $radius-rounded
border-top-left-radius: $radius-rounded
padding-left: 1.25em
// Sizes
&.is-small
font-size: $size-small
&.is-medium
font-size: $size-medium
&.is-large
font-size: $size-large

View File

@ -0,0 +1,16 @@
/* Bulma Elements */
@charset "utf-8"
@import "box.sass"
@import "button.sass"
@import "container.sass"
@import "content.sass"
@import "icon.sass"
@import "image.sass"
@import "notification.sass"
@import "progress.sass"
@import "table.sass"
@import "tag.sass"
@import "title.sass"
@import "other.sass"

View File

@ -0,0 +1,24 @@
$box-color: $text !default
$box-background-color: $scheme-main !default
$box-radius: $radius-large !default
$box-shadow: 0 0.5em 1em -0.125em rgba($scheme-invert, 0.1), 0 0px 0 1px rgba($scheme-invert, 0.02) !default
$box-padding: 1.25rem !default
$box-link-hover-shadow: 0 0.5em 1em -0.125em rgba($scheme-invert, 0.1), 0 0 0 1px $link !default
$box-link-active-shadow: inset 0 1px 2px rgba($scheme-invert, 0.2), 0 0 0 1px $link !default
.box
@extend %block
background-color: $box-background-color
border-radius: $box-radius
box-shadow: $box-shadow
color: $box-color
display: block
padding: $box-padding
a.box
&:hover,
&:focus
box-shadow: $box-link-hover-shadow
&:active
box-shadow: $box-link-active-shadow

View File

@ -0,0 +1,325 @@
$button-color: $text-strong !default
$button-background-color: $scheme-main !default
$button-family: false !default
$button-border-color: $border !default
$button-border-width: $control-border-width !default
$button-padding-vertical: calc(0.5em - #{$button-border-width}) !default
$button-padding-horizontal: 1em !default
$button-hover-color: $link-hover !default
$button-hover-border-color: $link-hover-border !default
$button-focus-color: $link-focus !default
$button-focus-border-color: $link-focus-border !default
$button-focus-box-shadow-size: 0 0 0 0.125em !default
$button-focus-box-shadow-color: bulmaRgba($link, 0.25) !default
$button-active-color: $link-active !default
$button-active-border-color: $link-active-border !default
$button-text-color: $text !default
$button-text-decoration: underline !default
$button-text-hover-background-color: $background !default
$button-text-hover-color: $text-strong !default
$button-disabled-background-color: $scheme-main !default
$button-disabled-border-color: $border !default
$button-disabled-shadow: none !default
$button-disabled-opacity: 0.5 !default
$button-static-color: $text-light !default
$button-static-background-color: $scheme-main-ter !default
$button-static-border-color: $border !default
$button-colors: $colors !default
// The button sizes use mixins so they can be used at different breakpoints
=button-small
border-radius: $radius-small
font-size: $size-small
=button-normal
font-size: $size-normal
=button-medium
font-size: $size-medium
=button-large
font-size: $size-large
.button
@extend %control
@extend %unselectable
background-color: $button-background-color
border-color: $button-border-color
border-width: $button-border-width
color: $button-color
cursor: pointer
@if $button-family
font-family: $button-family
justify-content: center
padding-bottom: $button-padding-vertical
padding-left: $button-padding-horizontal
padding-right: $button-padding-horizontal
padding-top: $button-padding-vertical
text-align: center
white-space: nowrap
strong
color: inherit
.icon
&,
&.is-small,
&.is-medium,
&.is-large
height: 1.5em
width: 1.5em
&:first-child:not(:last-child)
+ltr-property("margin", calc(#{-1 / 2 * $button-padding-horizontal} - #{$button-border-width}), false)
+ltr-property("margin", $button-padding-horizontal / 4)
&:last-child:not(:first-child)
+ltr-property("margin", $button-padding-horizontal / 4, false)
+ltr-property("margin", calc(#{-1 / 2 * $button-padding-horizontal} - #{$button-border-width}))
&:first-child:last-child
margin-left: calc(#{-1 / 2 * $button-padding-horizontal} - #{$button-border-width})
margin-right: calc(#{-1 / 2 * $button-padding-horizontal} - #{$button-border-width})
// States
&:hover,
&.is-hovered
border-color: $button-hover-border-color
color: $button-hover-color
&:focus,
&.is-focused
border-color: $button-focus-border-color
color: $button-focus-color
&:not(:active)
box-shadow: $button-focus-box-shadow-size $button-focus-box-shadow-color
&:active,
&.is-active
border-color: $button-active-border-color
color: $button-active-color
// Colors
&.is-text
background-color: transparent
border-color: transparent
color: $button-text-color
text-decoration: $button-text-decoration
&:hover,
&.is-hovered,
&:focus,
&.is-focused
background-color: $button-text-hover-background-color
color: $button-text-hover-color
&:active,
&.is-active
background-color: bulmaDarken($button-text-hover-background-color, 5%)
color: $button-text-hover-color
&[disabled],
fieldset[disabled] &
background-color: transparent
border-color: transparent
box-shadow: none
@each $name, $pair in $button-colors
$color: nth($pair, 1)
$color-invert: nth($pair, 2)
&.is-#{$name}
background-color: $color
border-color: transparent
color: $color-invert
&:hover,
&.is-hovered
background-color: bulmaDarken($color, 2.5%)
border-color: transparent
color: $color-invert
&:focus,
&.is-focused
border-color: transparent
color: $color-invert
&:not(:active)
box-shadow: $button-focus-box-shadow-size bulmaRgba($color, 0.25)
&:active,
&.is-active
background-color: bulmaDarken($color, 5%)
border-color: transparent
color: $color-invert
&[disabled],
fieldset[disabled] &
background-color: $color
border-color: transparent
box-shadow: none
&.is-inverted
background-color: $color-invert
color: $color
&:hover,
&.is-hovered
background-color: bulmaDarken($color-invert, 5%)
&[disabled],
fieldset[disabled] &
background-color: $color-invert
border-color: transparent
box-shadow: none
color: $color
&.is-loading
&::after
border-color: transparent transparent $color-invert $color-invert !important
&.is-outlined
background-color: transparent
border-color: $color
color: $color
&:hover,
&.is-hovered,
&:focus,
&.is-focused
background-color: $color
border-color: $color
color: $color-invert
&.is-loading
&::after
border-color: transparent transparent $color $color !important
&:hover,
&.is-hovered,
&:focus,
&.is-focused
&::after
border-color: transparent transparent $color-invert $color-invert !important
&[disabled],
fieldset[disabled] &
background-color: transparent
border-color: $color
box-shadow: none
color: $color
&.is-inverted.is-outlined
background-color: transparent
border-color: $color-invert
color: $color-invert
&:hover,
&.is-hovered,
&:focus,
&.is-focused
background-color: $color-invert
color: $color
&.is-loading
&:hover,
&.is-hovered,
&:focus,
&.is-focused
&::after
border-color: transparent transparent $color $color !important
&[disabled],
fieldset[disabled] &
background-color: transparent
border-color: $color-invert
box-shadow: none
color: $color-invert
// If light and dark colors are provided
@if length($pair) >= 4
$color-light: nth($pair, 3)
$color-dark: nth($pair, 4)
&.is-light
background-color: $color-light
color: $color-dark
&:hover,
&.is-hovered
background-color: bulmaDarken($color-light, 2.5%)
border-color: transparent
color: $color-dark
&:active,
&.is-active
background-color: bulmaDarken($color-light, 5%)
border-color: transparent
color: $color-dark
// Sizes
&.is-small
+button-small
&.is-normal
+button-normal
&.is-medium
+button-medium
&.is-large
+button-large
// Modifiers
&[disabled],
fieldset[disabled] &
background-color: $button-disabled-background-color
border-color: $button-disabled-border-color
box-shadow: $button-disabled-shadow
opacity: $button-disabled-opacity
&.is-fullwidth
display: flex
width: 100%
&.is-loading
color: transparent !important
pointer-events: none
&::after
@extend %loader
+center(1em)
position: absolute !important
&.is-static
background-color: $button-static-background-color
border-color: $button-static-border-color
color: $button-static-color
box-shadow: none
pointer-events: none
&.is-rounded
border-radius: $radius-rounded
padding-left: calc(#{$button-padding-horizontal} + 0.25em)
padding-right: calc(#{$button-padding-horizontal} + 0.25em)
.buttons
align-items: center
display: flex
flex-wrap: wrap
justify-content: flex-start
.button
margin-bottom: 0.5rem
&:not(:last-child):not(.is-fullwidth)
+ltr-property("margin", 0.5rem)
&:last-child
margin-bottom: -0.5rem
&:not(:last-child)
margin-bottom: 1rem
// Sizes
&.are-small
.button:not(.is-normal):not(.is-medium):not(.is-large)
+button-small
&.are-medium
.button:not(.is-small):not(.is-normal):not(.is-large)
+button-medium
&.are-large
.button:not(.is-small):not(.is-normal):not(.is-medium)
+button-large
&.has-addons
.button
&:not(:first-child)
border-bottom-left-radius: 0
border-top-left-radius: 0
&:not(:last-child)
border-bottom-right-radius: 0
border-top-right-radius: 0
+ltr-property("margin", -1px)
&:last-child
+ltr-property("margin", 0)
&:hover,
&.is-hovered
z-index: 2
&:focus,
&.is-focused,
&:active,
&.is-active,
&.is-selected
z-index: 3
&:hover
z-index: 4
&.is-expanded
flex-grow: 1
flex-shrink: 1
&.is-centered
justify-content: center
&:not(.has-addons)
.button:not(.is-fullwidth)
margin-left: 0.25rem
margin-right: 0.25rem
&.is-right
justify-content: flex-end
&:not(.has-addons)
.button:not(.is-fullwidth)
margin-left: 0.25rem
margin-right: 0.25rem

View File

@ -0,0 +1,27 @@
$container-offset: (2 * $gap) !default
$container-max-width: $fullhd !default
.container
flex-grow: 1
margin: 0 auto
position: relative
width: auto
&.is-fluid
max-width: none !important
padding-left: $gap
padding-right: $gap
width: 100%
+desktop
max-width: $desktop - $container-offset
+until-widescreen
&.is-widescreen:not(.is-max-desktop)
max-width: min($widescreen, $container-max-width) - $container-offset
+until-fullhd
&.is-fullhd:not(.is-max-desktop):not(.is-max-widescreen)
max-width: min($fullhd, $container-max-width) - $container-offset
+widescreen
&:not(.is-max-desktop)
max-width: min($widescreen, $container-max-width) - $container-offset
+fullhd
&:not(.is-max-desktop):not(.is-max-widescreen)
max-width: min($fullhd, $container-max-width) - $container-offset

View File

@ -0,0 +1,155 @@
$content-heading-color: $text-strong !default
$content-heading-weight: $weight-semibold !default
$content-heading-line-height: 1.125 !default
$content-blockquote-background-color: $background !default
$content-blockquote-border-left: 5px solid $border !default
$content-blockquote-padding: 1.25em 1.5em !default
$content-pre-padding: 1.25em 1.5em !default
$content-table-cell-border: 1px solid $border !default
$content-table-cell-border-width: 0 0 1px !default
$content-table-cell-padding: 0.5em 0.75em !default
$content-table-cell-heading-color: $text-strong !default
$content-table-head-cell-border-width: 0 0 2px !default
$content-table-head-cell-color: $text-strong !default
$content-table-foot-cell-border-width: 2px 0 0 !default
$content-table-foot-cell-color: $text-strong !default
.content
@extend %block
// Inline
li + li
margin-top: 0.25em
// Block
p,
dl,
ol,
ul,
blockquote,
pre,
table
&:not(:last-child)
margin-bottom: 1em
h1,
h2,
h3,
h4,
h5,
h6
color: $content-heading-color
font-weight: $content-heading-weight
line-height: $content-heading-line-height
h1
font-size: 2em
margin-bottom: 0.5em
&:not(:first-child)
margin-top: 1em
h2
font-size: 1.75em
margin-bottom: 0.5714em
&:not(:first-child)
margin-top: 1.1428em
h3
font-size: 1.5em
margin-bottom: 0.6666em
&:not(:first-child)
margin-top: 1.3333em
h4
font-size: 1.25em
margin-bottom: 0.8em
h5
font-size: 1.125em
margin-bottom: 0.8888em
h6
font-size: 1em
margin-bottom: 1em
blockquote
background-color: $content-blockquote-background-color
+ltr-property("border", $content-blockquote-border-left, false)
padding: $content-blockquote-padding
ol
list-style-position: outside
+ltr-property("margin", 2em, false)
margin-top: 1em
&:not([type])
list-style-type: decimal
&.is-lower-alpha
list-style-type: lower-alpha
&.is-lower-roman
list-style-type: lower-roman
&.is-upper-alpha
list-style-type: upper-alpha
&.is-upper-roman
list-style-type: upper-roman
ul
list-style: disc outside
+ltr-property("margin", 2em, false)
margin-top: 1em
ul
list-style-type: circle
margin-top: 0.5em
ul
list-style-type: square
dd
+ltr-property("margin", 2em, false)
figure
margin-left: 2em
margin-right: 2em
text-align: center
&:not(:first-child)
margin-top: 2em
&:not(:last-child)
margin-bottom: 2em
img
display: inline-block
figcaption
font-style: italic
pre
+overflow-touch
overflow-x: auto
padding: $content-pre-padding
white-space: pre
word-wrap: normal
sup,
sub
font-size: 75%
table
width: 100%
td,
th
border: $content-table-cell-border
border-width: $content-table-cell-border-width
padding: $content-table-cell-padding
vertical-align: top
th
color: $content-table-cell-heading-color
&:not([align])
text-align: inherit
thead
td,
th
border-width: $content-table-head-cell-border-width
color: $content-table-head-cell-color
tfoot
td,
th
border-width: $content-table-foot-cell-border-width
color: $content-table-foot-cell-color
tbody
tr
&:last-child
td,
th
border-bottom-width: 0
.tabs
li + li
margin-top: 0
// Sizes
&.is-small
font-size: $size-small
&.is-medium
font-size: $size-medium
&.is-large
font-size: $size-large

View File

@ -0,0 +1 @@
@warn "The form.sass file is DEPRECATED. It has moved into its own /form folder. Please import sass/form/_all instead."

View File

@ -0,0 +1,21 @@
$icon-dimensions: 1.5rem !default
$icon-dimensions-small: 1rem !default
$icon-dimensions-medium: 2rem !default
$icon-dimensions-large: 3rem !default
.icon
align-items: center
display: inline-flex
justify-content: center
height: $icon-dimensions
width: $icon-dimensions
// Sizes
&.is-small
height: $icon-dimensions-small
width: $icon-dimensions-small
&.is-medium
height: $icon-dimensions-medium
width: $icon-dimensions-medium
&.is-large
height: $icon-dimensions-large
width: $icon-dimensions-large

View File

@ -0,0 +1,71 @@
$dimensions: 16 24 32 48 64 96 128 !default
.image
display: block
position: relative
img
display: block
height: auto
width: 100%
&.is-rounded
border-radius: $radius-rounded
&.is-fullwidth
width: 100%
// Ratio
&.is-square,
&.is-1by1,
&.is-5by4,
&.is-4by3,
&.is-3by2,
&.is-5by3,
&.is-16by9,
&.is-2by1,
&.is-3by1,
&.is-4by5,
&.is-3by4,
&.is-2by3,
&.is-3by5,
&.is-9by16,
&.is-1by2,
&.is-1by3
img,
.has-ratio
@extend %overlay
height: 100%
width: 100%
&.is-square,
&.is-1by1
padding-top: 100%
&.is-5by4
padding-top: 80%
&.is-4by3
padding-top: 75%
&.is-3by2
padding-top: 66.6666%
&.is-5by3
padding-top: 60%
&.is-16by9
padding-top: 56.25%
&.is-2by1
padding-top: 50%
&.is-3by1
padding-top: 33.3333%
&.is-4by5
padding-top: 125%
&.is-3by4
padding-top: 133.3333%
&.is-2by3
padding-top: 150%
&.is-3by5
padding-top: 166.6666%
&.is-9by16
padding-top: 177.7777%
&.is-1by2
padding-top: 200%
&.is-1by3
padding-top: 300%
// Sizes
@each $dimension in $dimensions
&.is-#{$dimension}x#{$dimension}
height: $dimension * 1px
width: $dimension * 1px

View File

@ -0,0 +1,50 @@
$notification-background-color: $background !default
$notification-code-background-color: $scheme-main !default
$notification-radius: $radius !default
$notification-padding: 1.25rem 2.5rem 1.25rem 1.5rem !default
$notification-padding-ltr: 1.25rem 2.5rem 1.25rem 1.5rem !default
$notification-padding-rtl: 1.25rem 1.5rem 1.25rem 2.5rem !default
$notification-colors: $colors !default
.notification
@extend %block
background-color: $notification-background-color
border-radius: $notification-radius
position: relative
+ltr
padding: $notification-padding-ltr
+rtl
padding: $notification-padding-rtl
a:not(.button):not(.dropdown-item)
color: currentColor
text-decoration: underline
strong
color: currentColor
code,
pre
background: $notification-code-background-color
pre code
background: transparent
& > .delete
+ltr-position(0.5rem)
position: absolute
top: 0.5rem
.title,
.subtitle,
.content
color: currentColor
// Colors
@each $name, $pair in $notification-colors
$color: nth($pair, 1)
$color-invert: nth($pair, 2)
&.is-#{$name}
background-color: $color
color: $color-invert
// If light and dark colors are provided
@if length($pair) >= 4
$color-light: nth($pair, 3)
$color-dark: nth($pair, 4)
&.is-light
background-color: $color-light
color: $color-dark

View File

@ -0,0 +1,39 @@
.block
@extend %block
.delete
@extend %delete
.heading
display: block
font-size: 11px
letter-spacing: 1px
margin-bottom: 5px
text-transform: uppercase
.highlight
@extend %block
font-weight: $weight-normal
max-width: 100%
overflow: hidden
padding: 0
pre
overflow: auto
max-width: 100%
.loader
@extend %loader
.number
align-items: center
background-color: $background
border-radius: $radius-rounded
display: inline-flex
font-size: $size-medium
height: 2em
justify-content: center
margin-right: 1.5rem
min-width: 2.5em
padding: 0.25rem 0.5rem
text-align: center
vertical-align: top

View File

@ -0,0 +1,71 @@
$progress-bar-background-color: $border-light !default
$progress-value-background-color: $text !default
$progress-border-radius: $radius-rounded !default
$progress-indeterminate-duration: 1.5s !default
$progress-colors: $colors !default
.progress
@extend %block
-moz-appearance: none
-webkit-appearance: none
border: none
border-radius: $progress-border-radius
display: block
height: $size-normal
overflow: hidden
padding: 0
width: 100%
&::-webkit-progress-bar
background-color: $progress-bar-background-color
&::-webkit-progress-value
background-color: $progress-value-background-color
&::-moz-progress-bar
background-color: $progress-value-background-color
&::-ms-fill
background-color: $progress-value-background-color
border: none
// Colors
@each $name, $pair in $progress-colors
$color: nth($pair, 1)
&.is-#{$name}
&::-webkit-progress-value
background-color: $color
&::-moz-progress-bar
background-color: $color
&::-ms-fill
background-color: $color
&:indeterminate
background-image: linear-gradient(to right, $color 30%, $progress-bar-background-color 30%)
&:indeterminate
animation-duration: $progress-indeterminate-duration
animation-iteration-count: infinite
animation-name: moveIndeterminate
animation-timing-function: linear
background-color: $progress-bar-background-color
background-image: linear-gradient(to right, $text 30%, $progress-bar-background-color 30%)
background-position: top left
background-repeat: no-repeat
background-size: 150% 150%
&::-webkit-progress-bar
background-color: transparent
&::-moz-progress-bar
background-color: transparent
&::-ms-fill
animation-name: none
// Sizes
&.is-small
height: $size-small
&.is-medium
height: $size-medium
&.is-large
height: $size-large
@keyframes moveIndeterminate
from
background-position: 200% 0
to
background-position: -200% 0

Some files were not shown because too many files have changed in this diff Show More