Compare commits

..

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

154 changed files with 16289 additions and 4149 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,6 @@ flask-login = "*"
sqlalchemy = "*"
requests = "*"
py3-validate-email = "*"
validators = "*"
[requires]
python_version = "3.8"

119
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "3a5cabc81d97143e23ec48af6c789d70ba16128d02104f8323100443b44e6b10"
"sha256": "57134ef6f8a30aa46c1ab6263e62e14edbb27d6df2911fc6b2140dde8c49d27c"
},
"pipfile-spec": 6,
"requires": {
@ -25,17 +25,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 +44,6 @@
],
"version": "==7.1.2"
},
"decorator": {
"hashes": [
"sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760",
"sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"
],
"version": "==4.4.2"
},
"dnspython": {
"hashes": [
"sha256:044af09374469c3a39eeea1a146e8cac27daec951f1f1f157b1962fc7cb9d1b7",
@ -158,23 +151,21 @@
},
"py3-validate-email": {
"hashes": [
"sha256:e5815a929c064face7b6e775f290f157ab52c1c88d56d27d031a02a185b991e3"
"sha256:3bbb264b49c0ae09afdb2738956f00b0e8dd7e079e2d079b2e9b6688de474d28"
],
"index": "pypi",
"version": "==0.2.12"
"version": "==0.2.10"
},
"pyyaml": {
"hashes": [
"sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
"sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
"sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
"sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e",
"sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
"sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
"sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
"sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
"sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a",
"sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
"sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
"sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
@ -184,11 +175,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 +190,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 +239,6 @@
],
"version": "==1.26.2"
},
"validators": {
"hashes": [
"sha256:0143dcca8a386498edaf5780cbd5960da1a4c85e0719f3ee5c9b41249c4fefbd",
"sha256:37cd9a9213278538ad09b5b9f9134266e7c226ab1fede1d500e29e0a8fbb9ea6"
],
"index": "pypi",
"version": "==0.18.2"
},
"werkzeug": {
"hashes": [
"sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43",

106
README.md
View File

@ -14,28 +14,13 @@ A comment system for Hugo, written in Python (and Javascript).
* EMail Blocklist
* Only outputs JSON, so templates can be done independently, enhancing customization. Using the comments via a partial
template in Hugo is the recommended way. See below for integration code.
* Antispam
* Email Validation
## Requirements
* A public webserver capable of running Python, Apache/NGINX and/or gunicorn. This server does not need to be the same as the
* A public webserver capable of running Apache/NGINX and/or gunicorn. This server does not need to be the same as the
server running the site, but it must have access to your CI/CD chain. Same server is of course easier to implement.
## Dependencies
* Python 3.8
* flask
* flask-cors
* flask-sqlalchemy
* flask-login
* antispam
* pyyaml
* requests
* py3-validate-email
* Recommended OS: Ubuntu 20, Debian Buster
* Recommended Server Software: Apache with libmodwsgi for Python 3
* GoHugo, but the json can also be used by Javascript and other languages
## How does it work?
@ -49,7 +34,7 @@ template.
## Setup
Run `git clone ssh://git@git.tuxstash.de:1235/gothseidank/labertasche.git` in the directory where you wish to host the comment
Run `ssh://git@git.tuxstash.de:1235/gothseidank/labertasche.git` in the directory where you wish to host the comment
system. For example, `/var/www/html`, I also recommend making use of `/srv/` or `/opt/`. It depends on you.
When everything is downloaded, create the directory `/etc/labertasche`. In this directory, we need 2 files:
@ -58,21 +43,21 @@ When everything is downloaded, create the directory `/etc/labertasche`. In this
* mail_credentials.json - you can find an example in the root directory.
Copy these files from the root directory of this app to the folder `/etc/labertasche`. Make sure to set ownership for
your user that runs your server later. I always do `chown user:www-data`, so Apache has only group rights and enable read-only
for the Apache user. I also recommend `chmod 700` for the directory and `chmod 600` for the files.
your user that runs your server later. I always do `chmown user:www-data`, so Apache has only group rights and enable read-only
for the Apache user.
Make sure to read the config and replace the values as needed. The mail configuration should need no explanation,
`labertasche.yaml` has comments. Feel free to bug me about more documentation regarding this. Pay special attention to
`labertasche.yaml` has comments. Feel free to bug about more documentation regarding this. Pay special attention to
secrets and passwords.
Now, for the server there are several options. I personally always host flask apps with Apache and libmodwsgi.
Now, for the server there are several options. I personally always host flask apps with Apache and mod_wsgi.
The config looks like this:
* [Apache](docs/apache-config.md)
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.
@ -80,12 +65,12 @@ Once you can see the administrative page, you can start integrating it into Hugo
### Javascript
In the project folder is a small javascript file. You will need to add this to Hugo. I suggest using Hugo's asset
pipeline to integrate it into your site and merge it with your current javascript.
One thing is important to know: this script only does the bare bones post request to the comment backend.
Any frontend work must be done by yourself, such as messages about minimum length etc.
But don't worry: The function is making use of a callback, where you can receive various messages with error codes
and act on them. See the javascript file for an example callback.
In the project folder is a small javascript file. You will need to load this into Hugo, I suggest using Hugo's asset
pipeline to integrate it into your site. One thing is important to know: this script only does the bare bones post request
to your comment backend. Anything else must be done by yourself, but don't worry: The function is making use of a callback,
so you can control what happens during the various stages.
TODO: Example using the javascript properly
### Hugo templates
@ -93,19 +78,14 @@ Remember the `labertasche.yaml` file? It asked you where the data folder of Hugo
various json files into that folder, in folders that describe your sections. So, for each category/section of your blog
where comments can be placed, one folder will be made. And for each page within that section it generates a json file.
Now create a new [partial](https://gohugo.io/templates/partials/) called "comments.html" (or something else).
Within that template the following structure is needed:
Now create a new partial called "comments.html" (or something else). Within that template the following structure is needed:
```
{{ $location := .Scratch.Get "location" }}
{{ 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}}
{{ end }}
{{ end }}
```
@ -125,70 +105,28 @@ Of course you will also need a few inputs and a button that submits the data.
Here is a base skeleton to start out:
```
<di id="labertasche-comment-section" data-remote="https://comments.example.com/comments/new">
<div>
<input type="text" maxlength=100 placeholder="Enter Email" id="labertasche-mail">
<textarea cols="10" rows="10" id="labertasche-text"></textarea>
<input type="button" onclick="labertasche_post_comment(this, labertasche_callback);">
</div>
```
This is the recommended element on each top post if you want to utilize replies:
```
<div class="">
<a href="#labertasche-comment-section"
onclick="labertasche_reply_to({{.comment_id}}, labertasche_reply_callback);">
reply
</a>
</div>
```
Please take note of the `id` on each element, these are mandatory, as well as the function call for the `onclick` event.
Again, style as needed and add more Javascript to your gusto. Make sure to implement the callback, otherwise the
Javascript will crash. The `data-remote=` needs to have the URL where you host this program, as well as the path to
the API endpoint.
Again, style as needed and add more Javascript to your gusto.
Inside your template `single.html`, or wherever you want to place comments, you will also need this:
Inside your template `single.html`, or wherever you want to place comments, you qwill also need this:
```
{{ $file := replaceRE "^(.*)[\\/]$" "data$1.json" .Page.RelPermalink }}
{{ .Scratch.Set "location" $file }}
{{ partial "partials/comments" . }}
```
There is no styling needed for this part!
After that and configuring labertasche correctly, the json files should be placed in your data folder and all you got
to do after that, is to rebuild Hugo and the new comment should appear.
## Watching for changes via systemd
Hugo accepts the `--watch` command without the `server` option:
`hugo --watch` is valid and it will watch the directory and rebuild the files, if something changes.
Knowing that, we can build a systemd service from that, which could look like this:
```
[Unit]
Description=Hugo
After=syslog.target
After=network.target
[Service]
RestartSec=2s
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/html/
ExecStart=/usr/local/bin/hugo --watch --minify --noChmod --cleanDestinationDir --gc
Restart=always
[Install]
WantedBy=multi-user.target
```
`-noChmod` is a very important switch for this, because it stops Hugo from adjusting the file permissions.
This comes in handy if you have a difference in user and group on your web server. `--cleanDestinationDir` and `--gc`
will clean old files out, so you don't have to worry about synching the public directory with the current content of
your static or assets dir. There will also be no old CSS files be lying around when using fingerprinting.
<!--suppress HtmlDeprecatedAttribute -->
<p align="center">

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

@ -6,16 +6,17 @@
// *********************************************************************************/
/*
Callback example.
Possible messages:
post-min-length
post-max-length
post-invalid-json
post-duplicate
post-internal-server-error
post-success
post-before-fetch
//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
// post-project-not-found
function labertasche_callback(state)
{
if (state === "post-before-fetch"){
@ -34,63 +35,20 @@ function labertasche_callback(state)
}
}
// Callback for initiating and cancelling replies.
// Posstible message: 'on' and 'off'
function labertasche_reply_callback(state)
{
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');
let comment = document.getElementById('labertasche-text').value;
let mail = document.getElementById('labertasche-mail').value;
if (mail.length <= 0 || comment.length < 40){
callback('post-min-length');
if(btn) {
return false;
btn.preventDefault();
}
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;
return
}
callback('post-before-fetch');
@ -107,7 +65,7 @@ function labertasche_post_comment(btn, callback)
body: JSON.stringify({ "email": mail,
"content": comment,
"location": window.location.pathname,
"replied_to": reply_value
"replied_to": null // TODO: future feature: replies?
})
})
.then(async function(response){
@ -120,5 +78,7 @@ function labertasche_post_comment(btn, callback)
})
// Don't reload the page
return false;
if (btn) {
btn.preventDefault();
}
}

44
labertasche.yaml Normal file
View File

@ -0,0 +1,44 @@
# /**********************************************************************************
# * _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://dev.localhost:1314/" # Url where the comment system is served
blog_url: "http://dev.localhost:1313/" # Url of your website
cookie-domain: "dev.localhost" # Url where the comment system is served
database_uri: "sqlite:///db/labertasche.db" # Database URI. See documentation. Default is sqlite.
secret: "6Gxvb52bIJCm2vfDsmWKzShKp1omrzVG" # CHANGE ME! THIS IS IMPORTANT!
output: "../../web/tuxstash.de/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: "../../web/tuxstash.de/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
# https://www.w3schools.com/charsets/ref_emoji_smileys.asp
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,26 +59,16 @@ 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
replied_to = new_comment['replied_to']
try:
if replied_to:
replied_to = int(replied_to)
# not a valid id at all
except ValueError:
return make_response(jsonify(status="bad-reply"), 400)
# Update values
new_comment.update({"content": content})
new_comment.update({"email": new_comment['email'].strip()})
new_comment.update({"location": location})
new_comment.update({"replied_to": replied_to})
new_comment.update({"replied_to": None}) # Not (yet?) implemented
# Check mail
if not sender.validate(new_comment['email']):
@ -108,25 +92,21 @@ def check_and_insert_new_comment(name):
if not email.is_allowed:
is_spam = True
if email.is_allowed:
# This forces the comment to be not spam if the address is in the allowed list,
# but the commenter will still need to confirm it to avoid brute
# force attacks against this feature
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
# Set existing location id
new_comment.update({'location_id': loc_query.first().id_location})
# TComments does not have this field
new_comment.pop("location")
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 +118,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 +131,31 @@ 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(e, file=stderr)
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)
export_location(location)
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 +163,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}"
export_location(location.id_location)
url = f"{settings.system['blog_url']}{location.location}#comment_{comment.comments_id}"
export_location(location.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"
export_location(location.id_location)
url = f"{settings.system['blog_url']}?deleted=true"
export_location(location.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,201 @@
#!/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
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>', methods=["POST", "GET"])
@login_required
def dashboard_review_spam(location=None):
all_locations = db.session.query(TLocation).all()
# Check post
if request.method == "POST":
location = request.form.get('selected_location')
# no parameters found
if location is None:
return render_template("review-spam.html", locations=all_locations, selected=location)
try:
if int(location) >= 1:
spam_comments = db.session.query(TComments).filter(TComments.location_id == location)\
.filter(TComments.is_spam == True)
return render_template("review-spam.html", locations=all_locations, selected=location,
spam_comments=spam_comments)
except ValueError:
pass
return render_template("review-spam.html", locations=all_locations, selected=location)
@bp_dashboard.route('/manage-comments/', methods=["POST", "GET"])
@bp_dashboard.route('/manage-comments/<int:location>', methods=["POST", "GET"])
@login_required
def dashboard_manage_regular_comments(location=None):
all_locations = db.session.query(TLocation).all()
# Check post
if request.method == "POST":
location = request.form.get('selected_location')
# no parameters found
if location is None:
return render_template("manage-comments.html", locations=all_locations, selected=location)
try:
if int(location) >= 1:
spam_comments = db.session.query(TComments).filter(TComments.location_id == location) \
.filter(TComments.is_spam == False)
return render_template("manage-comments.html", locations=all_locations, selected=location,
spam_comments=spam_comments)
except ValueError:
pass
return render_template("manage-comments.html", locations=all_locations, selected=location)
@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]
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]
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]
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]
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,81 +77,97 @@ 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 export_location(location_id: int) -> bool:
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: str) -> bool:
"""
Exports the comments for the location after the comment was accepted
:param location_id: The id of the store location to export
:param location: relative url of the hugo page
"""
try:
# flush before query
db.session.flush()
# Query
location = db.session.query(TLocation).filter(TLocation.id_location == location_id).first()
loc_query = db.session.query(TLocation).filter(TLocation.location == location).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.is_published == True) \
.filter(TComments.location_id == loc_query.id_location) \
.filter(TComments.replied_to == None)
bundle = {
"comments": [],
"replies": []
"comments": []
}
for comment in comments:
if comment.replied_to is not None:
bundle["replies"].append(alchemy_query_to_dict(comment))
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()
print(out)
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 +188,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 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
from validate_email import validate_email
from secrets import token_urlsafe
class Mail:
class mail:
def __init__(self):
path = Path("/etc/labertasche/mail_credentials.json")
@ -65,49 +61,40 @@ 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):
# validate email
is_valid = validate_email_or_fail(email_address=addr, check_regex=True, check_mx=False,
dns_timeout=10, use_blacklist=True, debug=False)
is_valid = validate_email(email_address=addr,
check_regex=True,
check_mx=False,
dns_timeout=10,
use_blacklist=True,
debug=False)
return is_valid

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,10 +16,10 @@ 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)
location_id = db.Column(db.Text, ForeignKey('t_location.id_location'), nullable=False)
# data
email = db.Column(db.Text, 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.Boolean, 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']

View File

@ -6,74 +6,55 @@
# * _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
# 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
# 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 +62,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

View File

@ -0,0 +1,131 @@
$table-color: $text-strong !default
$table-background-color: $scheme-main !default
$table-cell-border: 1px solid $border !default
$table-cell-border-width: 0 0 1px !default
$table-cell-padding: 0.5em 0.75em !default
$table-cell-heading-color: $text-strong !default
$table-head-cell-border-width: 0 0 2px !default
$table-head-cell-color: $text-strong !default
$table-foot-cell-border-width: 2px 0 0 !default
$table-foot-cell-color: $text-strong !default
$table-head-background-color: transparent !default
$table-body-background-color: transparent !default
$table-foot-background-color: transparent !default
$table-row-hover-background-color: $scheme-main-bis !default
$table-row-active-background-color: $primary !default
$table-row-active-color: $primary-invert !default
$table-striped-row-even-background-color: $scheme-main-bis !default
$table-striped-row-even-hover-background-color: $scheme-main-ter !default
$table-colors: $colors !default
.table
@extend %block
background-color: $table-background-color
color: $table-color
td,
th
border: $table-cell-border
border-width: $table-cell-border-width
padding: $table-cell-padding
vertical-align: top
// Colors
@each $name, $pair in $table-colors
$color: nth($pair, 1)
$color-invert: nth($pair, 2)
&.is-#{$name}
background-color: $color
border-color: $color
color: $color-invert
// Modifiers
&.is-narrow
white-space: nowrap
width: 1%
&.is-selected
background-color: $table-row-active-background-color
color: $table-row-active-color
a,
strong
color: currentColor
&.is-vcentered
vertical-align: middle
th
color: $table-cell-heading-color
&:not([align])
text-align: inherit
tr
&.is-selected
background-color: $table-row-active-background-color
color: $table-row-active-color
a,
strong
color: currentColor
td,
th
border-color: $table-row-active-color
color: currentColor
thead
background-color: $table-head-background-color
td,
th
border-width: $table-head-cell-border-width
color: $table-head-cell-color
tfoot
background-color: $table-foot-background-color
td,
th
border-width: $table-foot-cell-border-width
color: $table-foot-cell-color
tbody
background-color: $table-body-background-color
tr
&:last-child
td,
th
border-bottom-width: 0
// Modifiers
&.is-bordered
td,
th
border-width: 1px
tr
&:last-child
td,
th
border-bottom-width: 1px
&.is-fullwidth
width: 100%
&.is-hoverable
tbody
tr:not(.is-selected)
&:hover
background-color: $table-row-hover-background-color
&.is-striped
tbody
tr:not(.is-selected)
&:hover
background-color: $table-row-hover-background-color
&:nth-child(even)
background-color: $table-striped-row-even-hover-background-color
&.is-narrow
td,
th
padding: 0.25em 0.5em
&.is-striped
tbody
tr:not(.is-selected)
&:nth-child(even)
background-color: $table-striped-row-even-background-color
.table-container
@extend %block
+overflow-touch
overflow: auto
overflow-y: hidden
max-width: 100%

View File

@ -0,0 +1,138 @@
$tag-background-color: $background !default
$tag-color: $text !default
$tag-radius: $radius !default
$tag-delete-margin: 1px !default
$tag-colors: $colors !default
.tags
align-items: center
display: flex
flex-wrap: wrap
justify-content: flex-start
.tag
margin-bottom: 0.5rem
&:not(:last-child)
+ltr-property("margin", 0.5rem)
&:last-child
margin-bottom: -0.5rem
&:not(:last-child)
margin-bottom: 1rem
// Sizes
&.are-medium
.tag:not(.is-normal):not(.is-large)
font-size: $size-normal
&.are-large
.tag:not(.is-normal):not(.is-medium)
font-size: $size-medium
&.is-centered
justify-content: center
.tag
margin-right: 0.25rem
margin-left: 0.25rem
&.is-right
justify-content: flex-end
.tag
&:not(:first-child)
margin-left: 0.5rem
&:not(:last-child)
margin-right: 0
&.has-addons
.tag
+ltr-property("margin", 0)
&:not(:first-child)
+ltr-property("margin", 0, false)
+ltr
border-top-left-radius: 0
border-bottom-left-radius: 0
+rtl
border-top-right-radius: 0
border-bottom-right-radius: 0
&:not(:last-child)
+ltr
border-top-right-radius: 0
border-bottom-right-radius: 0
+rtl
border-top-left-radius: 0
border-bottom-left-radius: 0
.tag:not(body)
align-items: center
background-color: $tag-background-color
border-radius: $tag-radius
color: $tag-color
display: inline-flex
font-size: $size-small
height: 2em
justify-content: center
line-height: 1.5
padding-left: 0.75em
padding-right: 0.75em
white-space: nowrap
.delete
+ltr-property("margin", 0.25rem, false)
+ltr-property("margin", -0.375rem)
// Colors
@each $name, $pair in $tag-colors
$color: nth($pair, 1)
$color-invert: nth($pair, 2)
&.is-#{$name}
background-color: $color
color: $color-invert
// If a light and dark colors are provided
@if length($pair) > 3
$color-light: nth($pair, 3)
$color-dark: nth($pair, 4)
&.is-light
background-color: $color-light
color: $color-dark
// Sizes
&.is-normal
font-size: $size-small
&.is-medium
font-size: $size-normal
&.is-large
font-size: $size-medium
.icon
&:first-child:not(:last-child)
+ltr-property("margin", -0.375em, false)
+ltr-property("margin", 0.1875em)
&:last-child:not(:first-child)
+ltr-property("margin", 0.1875em, false)
+ltr-property("margin", -0.375em)
&:first-child:last-child
+ltr-property("margin", -0.375em, false)
+ltr-property("margin", -0.375em)
// Modifiers
&.is-delete
+ltr-property("margin", $tag-delete-margin, false)
padding: 0
position: relative
width: 2em
&::before,
&::after
background-color: currentColor
content: ""
display: block
left: 50%
position: absolute
top: 50%
transform: translateX(-50%) translateY(-50%) rotate(45deg)
transform-origin: center center
&::before
height: 1px
width: 50%
&::after
height: 50%
width: 1px
&:hover,
&:focus
background-color: darken($tag-background-color, 5%)
&:active
background-color: darken($tag-background-color, 10%)
&.is-rounded
border-radius: $radius-rounded
a.tag
&:hover
text-decoration: underline

View File

@ -0,0 +1,70 @@
$title-color: $text-strong !default
$title-family: false !default
$title-size: $size-3 !default
$title-weight: $weight-semibold !default
$title-line-height: 1.125 !default
$title-strong-color: inherit !default
$title-strong-weight: inherit !default
$title-sub-size: 0.75em !default
$title-sup-size: 0.75em !default
$subtitle-color: $text !default
$subtitle-family: false !default
$subtitle-size: $size-5 !default
$subtitle-weight: $weight-normal !default
$subtitle-line-height: 1.25 !default
$subtitle-strong-color: $text-strong !default
$subtitle-strong-weight: $weight-semibold !default
$subtitle-negative-margin: -1.25rem !default
.title,
.subtitle
@extend %block
word-break: break-word
em,
span
font-weight: inherit
sub
font-size: $title-sub-size
sup
font-size: $title-sup-size
.tag
vertical-align: middle
.title
color: $title-color
@if $title-family
font-family: $title-family
font-size: $title-size
font-weight: $title-weight
line-height: $title-line-height
strong
color: $title-strong-color
font-weight: $title-strong-weight
& + .highlight
margin-top: -0.75rem
&:not(.is-spaced) + .subtitle
margin-top: $subtitle-negative-margin
// Sizes
@each $size in $sizes
$i: index($sizes, $size)
&.is-#{$i}
font-size: $size
.subtitle
color: $subtitle-color
@if $subtitle-family
font-family: $subtitle-family
font-size: $subtitle-size
font-weight: $subtitle-weight
line-height: $subtitle-line-height
strong
color: $subtitle-strong-color
font-weight: $subtitle-strong-weight
&:not(.is-spaced) + .title
margin-top: $subtitle-negative-margin
// Sizes
@each $size in $sizes
$i: index($sizes, $size)
&.is-#{$i}
font-size: $size

View File

@ -0,0 +1,9 @@
/* Bulma Form */
@charset "utf-8"
@import "shared.sass"
@import "input-textarea.sass"
@import "checkbox-radio.sass"
@import "select.sass"
@import "file.sass"
@import "tools.sass"

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