├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE ├── README.md ├── Vagrantfile ├── app ├── brunch-config.js ├── elm │ ├── Data │ │ ├── Comment.elm │ │ ├── Init.elm │ │ └── User.elm │ ├── Main.elm │ ├── Markdown.elm │ ├── Models.elm │ ├── Msg.elm │ ├── Native │ │ └── Markdown.js │ ├── Ports.elm │ ├── Request │ │ ├── Comment.elm │ │ └── Init.elm │ ├── Style.elm │ ├── Stylesheets.elm │ ├── Time │ │ └── DateTime │ │ │ └── Distance.elm │ ├── Update.elm │ ├── Util.elm │ ├── View.elm │ ├── elm-package.json │ └── tests │ │ ├── Helpers │ │ └── Dates.elm │ │ ├── Tests.elm │ │ └── elm-package.json ├── js │ └── oration.js ├── package.json └── static │ ├── favicon.ico │ ├── index.html │ ├── post-1.html │ └── post-2.html ├── logo ├── logo_b.svg ├── logo_w.svg └── logo_wbl.svg ├── migrations ├── .gitkeep └── 20170719094701_create_oration │ ├── down.sql │ └── up.sql ├── oration.yaml ├── src ├── config.rs ├── data.rs ├── db.rs ├── errors.rs ├── main.rs ├── models │ ├── comments │ │ └── mod.rs │ ├── mod.rs │ ├── preferences │ │ └── mod.rs │ └── threads │ │ └── mod.rs ├── notify.rs ├── schema.rs ├── static_files.rs └── tests.rs └── staging ├── config ├── nginx.conf ├── nginx.vhost.conf └── oration.service ├── prepare.yml ├── services.yml └── staging.yml /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | public 3 | **/*.rs.bk 4 | *~ 5 | app/node_modules 6 | app/elm/elm-stuff 7 | app/elm/tests/elm-stuff 8 | app/js/main.js 9 | app/css/main.css 10 | app/css/oration.css 11 | app/package-lock.json 12 | Cargo.lock 13 | *.db 14 | *.retry 15 | .env 16 | .vagrant 17 | staging/deploy 18 | releases 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | matrix: 2 | include: 3 | - language: rust 4 | rust: nightly 5 | cache: cargo 6 | sudo: true 7 | dist: trusty 8 | os: linux 9 | addons: 10 | apt: 11 | packages: 12 | - sqlite3 13 | before_script: 14 | - ( cargo install diesel_cli --no-default-features --features sqlite || true ) 15 | - export PATH=$PATH:~/.cargo/bin 16 | - echo "DATABASE_URL=oration.db" > .env 17 | - diesel migration run 18 | - cargo update 19 | script: 20 | - cargo build 21 | - cargo test 22 | after_success: 23 | # Upload docs 24 | - | 25 | if [[ "$TRAVIS_PULL_REQUEST" = "false" && "$TRAVIS_BRANCH" == "master" ]]; then 26 | cargo rustdoc -- --document-private-items && 27 | cp logo/logo_wbl.svg target/doc/ && 28 | echo "" > target/doc/index.html && 29 | git clone https://github.com/davisp/ghp-import.git && 30 | ./ghp-import/ghp_import.py -n -p -f -m "Documentation upload" -r https://"$GH_TOKEN"@github.com/"$TRAVIS_REPO_SLUG.git" target/doc && 31 | echo "Uploaded documentation" 32 | fi 33 | # Coverage report 34 | - | 35 | `RUSTFLAGS="--cfg procmacro2_semver_exempt" cargo install cargo-tarpaulin` 36 | bash <(curl https://raw.githubusercontent.com/xd009642/tarpaulin/master/travis-install.sh) 37 | cargo tarpaulin --out Xml 38 | bash <(curl -s https://codecov.io/bash) 39 | echo "Uploaded code coverage" 40 | - language: node_js 41 | node_js: node 42 | os: linux 43 | cache: 44 | directories: 45 | - sysconfcpus 46 | - node_modules 47 | - app/elm/elm-stuff/build-artifacts 48 | - app/elm/tests/elm-stuff/build-artifacts 49 | before_install: 50 | - | # epic build time improvement - see https://github.com/elm-lang/elm-compiler/issues/1473#issuecomment-245704142 51 | if [ ! -d sysconfcpus/bin ]; then 52 | git clone https://github.com/obmarg/libsysconfcpus.git; 53 | cd libsysconfcpus; 54 | ./configure --prefix=$TRAVIS_BUILD_DIR/sysconfcpus; 55 | make && make install; 56 | cd ..; 57 | fi 58 | install: 59 | - npm install -g elm@0.18 elm-test elm-coverage elm-css 60 | - mv $(npm config get prefix)/bin/elm-make $(npm config get prefix)/bin/elm-make-old 61 | - echo -e "#\!/bin/bash\\n\\necho \"Running elm-make with sysconfcpus -n 2\"\\n\\n$TRAVIS_BUILD_DIR/sysconfcpus/bin/sysconfcpus -n 2 elm-make-old \"\$@\"" > $(npm config get prefix)/bin/elm-make 62 | - chmod +x $(npm config get prefix)/bin/elm-make 63 | script: 64 | - cd app/elm 65 | - elm-make --yes 66 | - elm-css Stylesheets.elm 67 | - elm-coverage . 68 | - bash <(curl -s https://codecov.io/bash) 69 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at tim@neophilus.net. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Thank you for considering contributing to Oration. 4 | 5 | Oration is an open source project and we love to receive contributions from our community — you! There are many ways to contribute, from improving the documentation, submitting bug reports and feature requests or writing code which can be incorporated into Oration itself. 6 | As Oration is still pre 0.1, there are no channels other than the issue tracker setup for communication. If you have a support question, please file an issue with the tracker. 7 | This may change as Oration grows. 8 | 9 | # Responsibilities 10 | 11 | * Create issues for any major changes and enhancements that you wish to make. Discuss things transparently and get community feedback. 12 | * Keep feature versions as small as possible, preferably one new feature per version. 13 | * Be welcoming to newcomers and encourage diverse new contributors from all backgrounds. See our [Code of Conduct](CODE_OF_CONDUCT.md) and the [Rust Community Code of Conduct](https://www.rust-lang.org/en-US/conduct.html). 14 | * External services such as Gravatar need to be considered by the community before inclusion. Even then, they should be completely optional. 15 | 16 | # Your First Contribution 17 | 18 | Unsure where to begin contributing to Oration? You can start by looking through the tracker and assign yourself to any issue that currently do not have an assignee. 19 | Issues labeled Help wanted in particular. 20 | 21 | Working on your first Pull Request? You can learn how from this *free* series, [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). 22 | If a maintainer asks you to "rebase" your PR, they're saying that a lot of code has changed, and that you need to update your branch so it's easier to merge. 23 | 24 | # How to report a bug 25 | 26 | If you find a security vulnerability, do NOT open an issue. Email tim@neophilus.net instead. 27 | 28 | In order to determine whether you are dealing with a security issue, ask yourself these two questions: 29 | * Can I access something that's not mine, or something I shouldn't have access to? 30 | * Can I disable something for other people? 31 | 32 | If the answer to either of those two questions are "yes", then you're probably dealing with a security issue. Note that even if you answer "no" to both questions, you may still be dealing with a security issue, so if you're unsure, just email. 33 | 34 | ---- 35 | 36 | When filing an issue, make sure to answer/consider these five questions: 37 | 38 | 1. Have you updated to the latest rust nightly? 39 | 2. What operating system and processor architecture are you using? 40 | 3. What did you do? 41 | 4. What did you expect to see? 42 | 5. What did you see instead? 43 | 44 | # How to suggest a feature or enhancement 45 | 46 | Oration aims to be a fast, lightweight and secure. External resources such as Gravatar therefore remain a matter of contention. 47 | With that being said, if you find yourself wishing for a feature that doesn't exist in Oration, you are probably not alone. 48 | There are bound to be others out there with similar needs. Many of the features that Oration has today have been added because our users saw the need. 49 | Open an issue on our issues list on GitHub which describes the feature you would like to see, why you need it, and how it should work. 50 | The community will decide on its adoption and how to best implement it. 51 | 52 | # Code review process 53 | 54 | For the moment, Libbum will review your PR's whenever there is available time. Feel free to give him a prod if needed. 55 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "oration" 3 | version = "0.1.0" 4 | authors = ["Tim DuBois "] 5 | 6 | [dependencies] 7 | dotenv = "0.13" 8 | rand = "0.5" 9 | rocket = "0.3" 10 | rocket_codegen = "0.3" 11 | rocket_contrib = "0.3" 12 | diesel = { version = "1.3", features = ["sqlite","chrono"] } 13 | r2d2-diesel = "1.0" 14 | r2d2 = "0.8" 15 | serde = "1.0" 16 | serde_json = "1.0" 17 | serde_derive = "1.0" 18 | bincode = "1.0" 19 | error-chain = "0.12" 20 | log = "0.4" 21 | yansi = "0.4" 22 | serde_yaml = "0.7" 23 | chrono = { version = "0.4", features = ["serde"] } 24 | reqwest = "0.8" 25 | argon2rs = { version = "0.2", features = ["simd"] } 26 | rust-crypto = "^0.2" 27 | itertools = "0.7" 28 | petgraph = "0.4" 29 | lettre = "0.8" 30 | lettre_email = "0.8" 31 | lazy_static = "1.0" 32 | regex = "1.0" 33 | bloomfilter = "0.0.12" 34 | # Packages below are needed for static production builds. 35 | openssl-sys = "0.9" 36 | openssl-probe = "0.1" 37 | libsqlite3-sys = { version = "0.9.1", features = ["bundled"] } #This needs to match the current diesel build 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Tim DuBois 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | A Rocket/Elm self hosted commenting system for static sites. 4 | 5 | Inspired by [Isso](https://posativ.org/isso/), which is a welcomed change from Disqus. 6 | However, the codebase is unmaintained and [security concerns](https://axiomatic.neophilus.net/posts/2017-04-16-from-disqus-to-isso.html) abound. 7 | 8 | *Oration* aims to be a fast, lightweight and secure platform for your comments. Nothing more, but importantly, nothing less. 9 | 10 | --- 11 | 12 | Oration is currently in an early stage of development, but [v0.1.1](https://github.com/Libbum/oration/releases/tag/v0.1.1) is usable now with minimal setup and a good deal of front facing features. 13 | Administration, porting from other commenting systems and a number of additional features are [planned](https://github.com/Libbum/oration/milestones) with a roadmap targeting a complete public release at v0.3. 14 | 15 | Contributions are welcome, please see our [guidelines](CONTRIBUTING.md) and [Code of Conduct](CODE_OF_CONDUCT.md). 16 | 17 | # Get it running now 18 | 19 | A static binary for the backend that runs on any Linux machine can be found in the [release tarball](https://github.com/Libbum/oration/releases/download/v0.1/oration-v0.1.tar.gz), along with a configuration file and minified `oration.{js,css}` files for you to put in your blog files. 20 | 21 | A staging virtual machine using [Vagrant](https://www.vagrantup.com/) and [Ansible](https://www.ansible.com/) is available if you wish to build a test machine direct from the source, although this will require a few more development tools to be installed on your system (like docker for instance). 22 | Please read the comments in [`staging/prepare.yml`](staging/prepare.yml) to setup the standalone build system. 23 | However, this staging setup shows you exactly how to put oration behind an [Nginx proxy](staging/config/nginx.vhost.conf) with hardened security headers, once you have it [running as a service](staging/config/oration.service). 24 | 25 | Before running the service, make sure an `.env` file that points to the location of an sqlite database initialised with [these commands](migrations/20170719094701_create_oration/up.sql), and the [configuration file](oration.yaml) with details specific for your machine both exist in the directory oration is located in. 26 | 27 | On the front end, it's simply a manner of uploading the css and js files to your public directory, and editing your blog posts to point to these assets. 28 | An example of this can be seen [here](app/static/post-1.html). 29 | 30 | More complete documentation is on the way. 31 | 32 | # Development Startup 33 | 34 | ```bash 35 | $ echo DATABASE_URL=oration.db > .env 36 | $ diesel migration run 37 | $ cd app/elm 38 | $ elm-package install 39 | $ cd .. 40 | $ brunch build 41 | $ cd .. 42 | $ cargo run 43 | ``` 44 | 45 | for live reloading of `app` files: 46 | 47 | ```bash 48 | $ cd app 49 | $ npm run watch 50 | ``` 51 | 52 | Until such time as I fix the build system, you'll also need to do some finicky stuff to get the style sheets building correctly. 53 | 54 | From the `app` directory: 55 | 1. `mkdir css` 56 | 2. `npm run watch` 57 | 3. Edit `elm/Stylesheets.elm` and save it. 58 | 59 | # Documentation 60 | 61 | Documentation of current backend methods can be viewed [here](https://libbum.github.io/oration/oration/index.html). 62 | 63 | # Options 64 | 65 | Code highlighting is done with [prism.js](http://prismjs.com/). 66 | The default syntax pack and a few extra markups are obtained via a CDN here, although you may wish to modify the allowable languages used on your blog. 67 | Replace the default pack with one customised from [here](http://prismjs.com/download.html) to achive this. 68 | The CDN isn't a bad idea however, and pulling multiple files the example here is all done over http v2, so it's pretty fast. 69 | 70 | In the same manner, you may change the theme of the syntax highlighting by choosing [another theme](https://github.com/PrismJS/prism/tree/gh-pages/themes). 71 | Oration uses the default in the example file. 72 | 73 | These changes should be in your own html files, an example can be seen in the bundled [index.html](app/static/index.html) header. 74 | 75 | 76 | ## License 77 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FLibbum%2Foration.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2FLibbum%2Foration?ref=badge_shield) 78 | 79 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FLibbum%2Foration.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2FLibbum%2Foration?ref=badge_large) 80 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | # You'll need the vagrant-triggers plugin for this file to function correctly: 5 | # $ vagrant plugin install vagrant-triggers 6 | 7 | Vagrant.configure("2") do |config| 8 | config.vm.box = "debian/jessie64" 9 | 10 | config.vm.network :forwarded_port, guest: 80, host: 8600 11 | 12 | config.vm.provision "ansible" do |ansible| 13 | ansible.playbook = "staging/staging.yml" 14 | ansible.limit = "all" 15 | ansible.verbose = "v" 16 | end 17 | 18 | config.vm.provision "ansible", run: "always" do |ansible| 19 | ansible.playbook = "staging/services.yml" 20 | ansible.limit = "all" 21 | ansible.verbose = "v" 22 | end 23 | 24 | config.vm.synced_folder "public", "/vagrant/public_html", create: true, type: "rsync" 25 | config.vm.synced_folder "staging/deploy", "/vagrant/app", create: true, type: "rsync" 26 | config.vm.synced_folder ".", "/vagrant", disabled: true 27 | 28 | config.vm.post_up_message = "The Oration staging service is now available at http://localhost:8600" 29 | 30 | config.trigger.before [:up, :resume, :reload] do 31 | info "Building files for staging environment" 32 | run "ansible-playbook -i 'localhost,' -c local staging/prepare.yml" 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/brunch-config.js: -------------------------------------------------------------------------------- 1 | exports.config = { 2 | files: { 3 | javascripts: { 4 | joinTo: "js/oration.js" 5 | }, 6 | stylesheets: { 7 | joinTo: "css/oration.css" 8 | } 9 | }, 10 | conventions: { 11 | // This option sets where we should place non-css and non-js assets in. 12 | // By default, we set this to "/assets/static". Files in this directory 13 | // will be copied to `paths.public`, which is set below to "../public". 14 | assets: /^(static)/, 15 | ignored: /elm-stuff/ 16 | }, 17 | // paths configuration 18 | paths: { 19 | // Dependencies and current project directories to watch 20 | watched: ["static", "js", "css", "elm"], 21 | // Where to compile files to 22 | public: "../public" 23 | }, 24 | plugins: { 25 | babel: { 26 | ignore: [/main.js$/] 27 | }, 28 | elmBrunch: { 29 | elmFolder: "elm", 30 | mainModules: ["Main.elm"], 31 | makeParameters: ["--warn","--debug"], 32 | outputFolder: "../js" 33 | }, 34 | elmCss: { 35 | projectDir: "elm", 36 | sourcePath: "Stylesheets.elm", 37 | pattern: "Style.elm", 38 | outputDir: "../css" 39 | }, 40 | cssnano: { 41 | preset: [ 42 | 'default', 43 | {discardComments: {removeAll: true}} 44 | ] 45 | } 46 | }, 47 | modules: { 48 | autoRequire: { 49 | "js/oration.js": ["js/oration"], 50 | "css/oration.css": ["css/oration"] 51 | } 52 | }, 53 | overrides: { 54 | production: { 55 | plugins: { 56 | elmBrunch: { 57 | makeParameters: ["--warn"] 58 | } 59 | } 60 | } 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /app/elm/Data/Comment.elm: -------------------------------------------------------------------------------- 1 | module Data.Comment exposing (Comment, Edited, Inserted, Responses(Responses), count, decoder, delete, disableVote, dislike, editDecoder, encode, getText, insertDecoder, insertNew, like, readOnly, toggleVisible, update) 2 | 3 | import Json.Decode as Decode exposing (Decoder) 4 | import Json.Decode.Extra as DecodeExtra 5 | import Json.Decode.Pipeline exposing (decode, hardcoded, required) 6 | import Json.Encode as Encode exposing (Value) 7 | import Json.Encode.Extra as EncodeExtra 8 | import Maybe.Extra exposing ((?), isNothing, values) 9 | import Time.DateTime exposing (DateTime) 10 | import Util exposing ((=>)) 11 | 12 | 13 | type alias Comment = 14 | { text : String 15 | , author : Maybe String 16 | , hash : String 17 | , created : DateTime 18 | , id : Int 19 | , votes : Int 20 | , children : Responses 21 | , visible : Bool 22 | , editable : Bool 23 | , votable : Bool 24 | } 25 | 26 | 27 | type Responses 28 | = Responses (List Comment) 29 | 30 | 31 | type alias Inserted = 32 | { id : Int 33 | , parent : Maybe Int 34 | , author : Maybe String 35 | } 36 | 37 | 38 | type alias Edited = 39 | { id : Int 40 | , author : Maybe String 41 | , hash : String 42 | , text : String 43 | } 44 | 45 | 46 | 47 | {- TOTAL COUNT -} 48 | 49 | 50 | count : List Comment -> Int 51 | count = 52 | foldl (\_ acc -> acc + 1) 0 53 | 54 | 55 | 56 | {- STRUCTURE UPDATES -} 57 | 58 | 59 | insertNew : Inserted -> ( String, String, DateTime, List Comment ) -> List Comment 60 | insertNew insert current = 61 | let 62 | ( commentText, hash, now, comments ) = 63 | current 64 | 65 | newComment = 66 | { text = commentText 67 | , author = insert.author 68 | , hash = hash 69 | , created = now 70 | , id = insert.id 71 | , votes = 0 72 | , children = Responses [] 73 | , visible = True 74 | , editable = True 75 | , votable = False 76 | } 77 | in 78 | if isNothing insert.parent then 79 | comments ++ List.singleton newComment 80 | else 81 | List.map (\comment -> injectNew insert newComment comment) comments 82 | 83 | 84 | injectNew : Inserted -> Comment -> Comment -> Comment 85 | injectNew insert newComment comment = 86 | let 87 | children = 88 | if comment.id == insert.parent ? -1 then 89 | case comment.children of 90 | Responses responses -> 91 | Responses <| responses ++ List.singleton newComment 92 | else 93 | case comment.children of 94 | Responses responses -> 95 | Responses <| List.map (\response -> injectNew insert newComment response) responses 96 | in 97 | { comment | children = children } 98 | 99 | 100 | update : Edited -> List Comment -> List Comment 101 | update edit comments = 102 | List.map (\comment -> injectUpdates edit comment) comments 103 | 104 | 105 | injectUpdates : Edited -> Comment -> Comment 106 | injectUpdates edit comment = 107 | if edit.id == comment.id then 108 | { comment 109 | | text = edit.text 110 | , author = edit.author 111 | , hash = edit.hash 112 | , editable = True 113 | } 114 | else 115 | mapChildren edit comment injectUpdates 116 | 117 | 118 | toggleVisible : Int -> List Comment -> List Comment 119 | toggleVisible id comments = 120 | List.map (\comment -> switchVisible id comment) comments 121 | 122 | 123 | switchVisible : Int -> Comment -> Comment 124 | switchVisible id comment = 125 | if comment.id == id then 126 | { comment | visible = not comment.visible } 127 | else 128 | mapChildren id comment switchVisible 129 | 130 | 131 | delete : Int -> List Comment -> List Comment 132 | delete id comments = 133 | List.map (\comment -> filterComment id comment) comments 134 | |> values 135 | 136 | 137 | filterComment : Int -> Comment -> Maybe Comment 138 | filterComment id comment = 139 | if comment.id == id then 140 | let 141 | --Pure deletes only happen on comments with no children, so only filter if that's the case 142 | noChildren = 143 | case comment.children of 144 | Responses responses -> 145 | List.isEmpty responses 146 | in 147 | if noChildren then 148 | Nothing 149 | else 150 | --We must display a masked delete 151 | let 152 | children = 153 | case comment.children of 154 | Responses responses -> 155 | Responses <| values <| List.map (\response -> filterComment id response) responses 156 | in 157 | Just 158 | { comment 159 | | children = children 160 | , author = Nothing 161 | , hash = "" 162 | , text = "" 163 | , votes = 0 164 | , votable = False 165 | } 166 | else 167 | let 168 | children = 169 | case comment.children of 170 | Responses responses -> 171 | Responses <| values <| List.map (\response -> filterComment id response) responses 172 | in 173 | Just { comment | children = children } 174 | 175 | 176 | readOnly : Int -> List Comment -> List Comment 177 | readOnly id comments = 178 | List.map (\comment -> removeEditable id comment) comments 179 | 180 | 181 | removeEditable : Int -> Comment -> Comment 182 | removeEditable id comment = 183 | if comment.id == id then 184 | { comment | editable = False } 185 | else 186 | mapChildren id comment removeEditable 187 | 188 | 189 | like : Int -> List Comment -> List Comment 190 | like id comments = 191 | List.map (\comment -> voteComment ( id, True ) comment) comments 192 | 193 | 194 | dislike : Int -> List Comment -> List Comment 195 | dislike id comments = 196 | List.map (\comment -> voteComment ( id, False ) comment) comments 197 | 198 | 199 | voteComment : ( Int, Bool ) -> Comment -> Comment 200 | voteComment ( id, like ) comment = 201 | if comment.id == id then 202 | let 203 | count = 204 | case like of 205 | True -> 206 | comment.votes + 1 207 | 208 | False -> 209 | comment.votes - 1 210 | in 211 | { comment 212 | | votes = count 213 | , votable = False 214 | } 215 | else 216 | mapChildren ( id, like ) comment voteComment 217 | 218 | 219 | disableVote : Int -> List Comment -> List Comment 220 | disableVote id comments = 221 | List.map (\comment -> removeVotable id comment) comments 222 | 223 | 224 | removeVotable : Int -> Comment -> Comment 225 | removeVotable id comment = 226 | if comment.id == id then 227 | { comment | votable = False } 228 | else 229 | mapChildren id comment removeVotable 230 | 231 | 232 | mapChildren : a -> Comment -> (a -> Comment -> Comment) -> Comment 233 | mapChildren value comment operation = 234 | let 235 | children = 236 | case comment.children of 237 | Responses responses -> 238 | Responses <| List.map (\response -> operation value response) responses 239 | in 240 | { comment | children = children } 241 | 242 | 243 | 244 | {- INFORMATION GATHERING -} 245 | 246 | 247 | getText : Int -> List Comment -> String 248 | getText id comments = 249 | let 250 | --id is unique, so we will only find one comment that isn't empty, 251 | --we can take the head of the filtered list 252 | found = 253 | foldl (\y ys -> findText id y :: ys) [] comments 254 | |> List.filter (not << String.isEmpty) 255 | |> List.head 256 | in 257 | case found of 258 | Just text -> 259 | text 260 | 261 | Nothing -> 262 | "" 263 | 264 | 265 | findText : Int -> Comment -> String 266 | findText id comment = 267 | if comment.id == id then 268 | comment.text 269 | else 270 | "" 271 | 272 | 273 | 274 | {- RECURSIVE ABILITIES -} 275 | 276 | 277 | foldl : (Comment -> b -> b) -> b -> List Comment -> b 278 | foldl f = 279 | List.foldl 280 | (\c acc -> 281 | case c.children of 282 | Responses responses -> 283 | foldl f (f c acc) responses 284 | ) 285 | 286 | 287 | 288 | {- SERIALIZATION -} 289 | 290 | 291 | decoder : Decoder Comment 292 | decoder = 293 | decode Comment 294 | |> required "text" Decode.string 295 | |> required "author" (Decode.nullable Decode.string) 296 | |> required "hash" Decode.string 297 | |> required "created" decodeDate 298 | |> required "id" Decode.int 299 | |> required "votes" Decode.int 300 | |> required "children" decodeResponses 301 | |> hardcoded True 302 | |> hardcoded False 303 | |> hardcoded True 304 | 305 | 306 | decodeResponses : Decoder Responses 307 | decodeResponses = 308 | Decode.map Responses (Decode.list (Decode.lazy (\_ -> decoder))) 309 | 310 | 311 | decodeDate : Decoder DateTime 312 | decodeDate = 313 | Decode.string 314 | |> Decode.andThen (Time.DateTime.fromISO8601 >> DecodeExtra.fromResult) 315 | 316 | 317 | encode : Comment -> Value 318 | encode comment = 319 | Encode.object 320 | [ "text" => Encode.string comment.text 321 | , "author" => EncodeExtra.maybe Encode.string comment.author 322 | , "hash" => Encode.string comment.hash 323 | ] 324 | 325 | 326 | insertDecoder : Decoder Inserted 327 | insertDecoder = 328 | decode Inserted 329 | |> required "id" Decode.int 330 | |> required "parent" (Decode.nullable Decode.int) 331 | |> required "author" (Decode.nullable Decode.string) 332 | 333 | 334 | editDecoder : Decoder Edited 335 | editDecoder = 336 | decode Edited 337 | |> required "id" Decode.int 338 | |> required "author" (Decode.nullable Decode.string) 339 | |> required "hash" Decode.string 340 | |> required "text" Decode.string 341 | -------------------------------------------------------------------------------- /app/elm/Data/Init.elm: -------------------------------------------------------------------------------- 1 | module Data.Init exposing (Init, decoder) 2 | 3 | import Json.Decode as Decode exposing (Decoder) 4 | import Json.Decode.Pipeline exposing (decode, required) 5 | 6 | 7 | type alias Init = 8 | { userIp : Maybe String 9 | , blogAuthor : Maybe String 10 | , editTimeout : Float 11 | } 12 | 13 | 14 | 15 | -- SERIALIZATION -- 16 | 17 | 18 | decoder : Decoder Init 19 | decoder = 20 | decode Init 21 | |> required "user_ip" (Decode.nullable Decode.string) 22 | |> required "blog_author" (Decode.nullable Decode.string) 23 | |> required "edit_timeout" Decode.float 24 | -------------------------------------------------------------------------------- /app/elm/Data/User.elm: -------------------------------------------------------------------------------- 1 | module Data.User exposing (Identity, User, decoder, encode, getIdentity) 2 | 3 | import Crypto.Hash 4 | import Json.Decode as Decode exposing (Decoder) 5 | import Json.Decode.Pipeline exposing (decode, optional, required) 6 | import Json.Encode as Encode exposing (Value) 7 | import Json.Encode.Extra as EncodeExtra 8 | import Maybe.Extra exposing ((?), isNothing) 9 | import Util exposing ((=>)) 10 | 11 | 12 | type alias User = 13 | { name : Maybe String 14 | , email : Maybe String 15 | , url : Maybe String 16 | , iphash : Maybe String 17 | , preview : Bool 18 | , identity : Identity 19 | } 20 | 21 | 22 | type alias Identity = 23 | String 24 | 25 | 26 | 27 | {- Hashes user information depending on available data -} 28 | 29 | 30 | getIdentity : User -> Identity 31 | getIdentity user = 32 | let 33 | data = 34 | [ user.name, user.email, user.url ] 35 | 36 | --I think Maybe.Extra.values could also be used here 37 | unwrapped = 38 | List.filterMap identity data 39 | in 40 | if List.all isNothing data then 41 | user.iphash ? "" 42 | else 43 | -- Join with b since it gives the authors' credentials a cool identicon 44 | Crypto.Hash.sha224 (String.join "b" unwrapped) 45 | 46 | 47 | 48 | -- SERIALIZATION -- 49 | 50 | 51 | decoder : Decoder User 52 | decoder = 53 | decode User 54 | |> required "name" (Decode.nullable Decode.string) 55 | |> required "email" (Decode.nullable Decode.string) 56 | |> required "url" (Decode.nullable Decode.string) 57 | |> required "iphash" (Decode.nullable Decode.string) 58 | |> required "preview" Decode.bool 59 | |> optional "identity" Decode.string "" 60 | 61 | 62 | encode : User -> Value 63 | encode user = 64 | Encode.object 65 | [ "name" => EncodeExtra.maybe Encode.string user.name 66 | , "email" => EncodeExtra.maybe Encode.string user.email 67 | , "url" => EncodeExtra.maybe Encode.string user.url 68 | , "iphash" => EncodeExtra.maybe Encode.string user.iphash 69 | , "preview" => Encode.bool user.preview 70 | , "identity" => Encode.string user.identity 71 | ] 72 | -------------------------------------------------------------------------------- /app/elm/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (main) 2 | 3 | import Http 4 | import Models exposing (Model, Status(Commenting)) 5 | import Msg exposing (Msg) 6 | import Navigation 7 | import Request.Comment 8 | import Request.Init 9 | import Task 10 | import Time.DateTime exposing (dateTime, zero) 11 | import Update exposing (currentDate, subscriptions, update) 12 | import View exposing (view) 13 | 14 | 15 | main : Program Never Model Msg 16 | main = 17 | Navigation.program Msg.Post { init = init, view = view, update = update, subscriptions = subscriptions } 18 | 19 | 20 | init : Navigation.Location -> ( Model, Cmd Msg ) 21 | init location = 22 | ( { comment = "" 23 | , parent = Nothing 24 | , user = 25 | { name = Nothing 26 | , email = Nothing 27 | , url = Nothing 28 | , preview = False 29 | , iphash = Nothing 30 | , identity = "" 31 | } 32 | , comments = [] 33 | , count = 0 34 | , post = location 35 | , title = "" 36 | , debug = "" 37 | , now = dateTime zero 38 | , editTimeout = 120 39 | , blogAuthor = "" 40 | , status = Commenting 41 | } 42 | , initialise location 43 | ) 44 | 45 | 46 | initialise : Navigation.Location -> Cmd Msg 47 | initialise location = 48 | let 49 | loadHashes = 50 | Request.Init.hashes 51 | |> Http.toTask 52 | 53 | loadComments = 54 | Request.Comment.comments location 55 | |> Http.toTask 56 | in 57 | Cmd.batch 58 | [ Task.attempt Msg.Hashes loadHashes 59 | , Task.attempt Msg.Comments loadComments 60 | , Task.perform Msg.NewDate currentDate 61 | ] 62 | -------------------------------------------------------------------------------- /app/elm/Markdown.elm: -------------------------------------------------------------------------------- 1 | module Markdown 2 | exposing 3 | ( Options 4 | , defaultOptions 5 | , toHtml 6 | , toHtmlWith 7 | ) 8 | 9 | {-| A library for markdown parsing. This is just an Elm API built on top of the 10 | [markdown-it](https://github.com/markdown-it/markdown-it) project which focuses on speed. 11 | 12 | 13 | # Parsing Markdown 14 | 15 | @docs toHtml 16 | 17 | 18 | # Parsing with Custom Options 19 | 20 | @docs Options, defaultOptions, toHtmlWith 21 | 22 | -} 23 | 24 | import Html exposing (Attribute, Html) 25 | import Native.Markdown 26 | 27 | 28 | {-| Turn a markdown string into an HTML element, using the `defaultOptions`. 29 | 30 | recipe : Html msg 31 | recipe = 32 | Markdown.toHtml [ class "recipe" ] """ 33 | 34 | # Apple Pie Recipe 35 | 36 | First, invent the universe. Then bake an apple pie. 37 | 38 | """ 39 | 40 | -} 41 | toHtml : List (Attribute msg) -> String -> Html msg 42 | toHtml attrs string = 43 | Native.Markdown.toHtml defaultOptions attrs string 44 | 45 | 46 | {-| Some parser options so you can tweak things for your particular case. 47 | 48 | - `githubFlavored` — overall reasonable improvements on the original 49 | markdown parser as described [here][gfm]. This includes stuff like [fenced 50 | code blocks][fenced] and [tables]. 51 | 52 | - `html` — this determines if raw HTML should be allowed. If you 53 | are parsing user markdown or user input can somehow reach the markdown 54 | parser, you should almost certainly turn off HTML. If it is just you 55 | writing markdown, turning HTML on is a nice way to do some tricks if 56 | it is needed. 57 | 58 | - `breaks` — This will automatically convert `\n` in paragraphs 59 | to `
`. 60 | 61 | - `langPrefix` — CSS language class prefix for fenced blocks. Can be 62 | useful for external highlighters. 63 | 64 | - `defaultHighlighting` — a default language to use for code blocks that do 65 | not have a language tag. So setting this to `Just "elm"` will treat all 66 | unlabeled code blocks as Elm code. (This relies on [highlight.js][highlight] 67 | as explained in the README [here](../#code-blocks).) 68 | 69 | - `linkify` — This will automatically convert URL-like text to links. 70 | 71 | - `typographer` — This will automatically upgrade quotes to the 72 | prettier versions and turn dashes into [em dashes or en dashes][dash] 73 | 74 | - `quotes` — replace the "smart" double/single quotes that typographer 75 | adds when it is enabled. Examples: 76 | 77 | -- Russian 78 | { doubleLeft = "«", doubleRight = "»", singleLeft = "„", singleRight = "“" } 79 | 80 | -- German 81 | { doubleLeft = "„", doubleRight = "“", singleLeft = "‚", singleRight = "‘" } 82 | 83 | -- French 84 | { doubleLeft = "«\xA0", doubleRight = "\xA0»", singleLeft = "‹\xA0", singleRight = "\xA0›" } 85 | 86 | [gfm]: https://help.github.com/articles/github-flavored-markdown/ 87 | [fenced]: https://help.github.com/articles/github-flavored-markdown/#fenced-code-blocks 88 | [tables]: https://help.github.com/articles/github-flavored-markdown/#tables 89 | [highlight]: https://highlightjs.org/ 90 | [dash]: http://en.wikipedia.org/wiki/Dash 91 | 92 | -} 93 | type alias Options = 94 | { githubFlavored : Bool 95 | , html : Bool 96 | , breaks : Bool 97 | , langPrefix : Maybe String 98 | , defaultHighlighting : Maybe String 99 | , linkify : Bool 100 | , typographer : Bool 101 | , quotes : 102 | Maybe 103 | { doubleLeft : String 104 | , doubleRight : String 105 | , singleLeft : String 106 | , singleRight : String 107 | } 108 | } 109 | 110 | 111 | {-| The `Options` used by the `toHtml` function. 112 | 113 | { githubFlavored = True 114 | , html = True 115 | , breaks = True 116 | , langPrefix = Just "custom-class-" 117 | , defaultHighlighting = Just "markdown" 118 | , linkify = True 119 | , typographer = True 120 | , quotes = Just { doubleLeft = "«", doubleRight = "»", singleLeft = "„", singleRight = "“" } 121 | } 122 | 123 | -} 124 | defaultOptions : Options 125 | defaultOptions = 126 | { githubFlavored = False 127 | , html = False 128 | , breaks = False 129 | , langPrefix = Just "prism " 130 | , defaultHighlighting = Nothing 131 | , linkify = False 132 | , typographer = False 133 | , quotes = Nothing 134 | } 135 | 136 | 137 | {-| Maybe you want to enable HTML input in your markdown. To accomplish this, 138 | you can use modified parsing options. 139 | 140 | options : Options 141 | options = 142 | { defaultOptions | html = True } 143 | 144 | toMarkdown : String -> Html 145 | toMarkdown userInput = 146 | Markdown.toHtmlWith options [] userInput 147 | 148 | -} 149 | toHtmlWith : Options -> List (Attribute msg) -> String -> Html msg 150 | toHtmlWith = 151 | Native.Markdown.toHtml 152 | -------------------------------------------------------------------------------- /app/elm/Models.elm: -------------------------------------------------------------------------------- 1 | module Models exposing (Model, Status(..)) 2 | 3 | import Data.Comment exposing (Comment, Inserted) 4 | import Data.User exposing (User) 5 | import Navigation exposing (Location) 6 | import Time.DateTime exposing (DateTime) 7 | 8 | 9 | type alias Model = 10 | { comment : String --TODO: Should probably rename this now 11 | , parent : Maybe Int 12 | , user : User 13 | , comments : List Comment 14 | , count : Int 15 | , post : Location 16 | , title : String 17 | , debug : String 18 | , now : DateTime 19 | , editTimeout : Float 20 | , blogAuthor : String 21 | , status : Status 22 | } 23 | 24 | 25 | type Status 26 | = Commenting 27 | | Replying 28 | | Editing 29 | -------------------------------------------------------------------------------- /app/elm/Msg.elm: -------------------------------------------------------------------------------- 1 | module Msg exposing (..) 2 | 3 | import Data.Comment exposing (Comment, Edited, Inserted) 4 | import Data.Init exposing (Init) 5 | import Http 6 | import Navigation exposing (Location) 7 | import Time exposing (Time) 8 | import Time.DateTime exposing (DateTime) 9 | 10 | 11 | type Msg 12 | = UpdateComment String 13 | | UpdateName (Maybe String) 14 | | UpdateEmail (Maybe String) 15 | | UpdateUrl (Maybe String) 16 | | UpdatePreview 17 | | SetPreview (Maybe String) 18 | | Count (Result Http.Error String) 19 | | Post Location 20 | | StoreUser 21 | | Title String 22 | | PostComment 23 | | PostConfirm (Result Http.Error Inserted) 24 | | Hashes (Result Http.Error Init) 25 | | Comments (Result Http.Error (List Comment)) 26 | | GetDate Time 27 | | NewDate DateTime 28 | | CommentReply Int 29 | | CommentEdit Int 30 | | SendEdit Int 31 | | CommentDelete Int 32 | | CommentLike Int 33 | | CommentDislike Int 34 | | EditConfirm (Result Http.Error Edited) 35 | | DeleteConfirm (Result Http.Error Int) 36 | | LikeConfirm (Result Http.Error Int) 37 | | DislikeConfirm (Result Http.Error Int) 38 | | ToggleCommentVisibility Int 39 | | HardenEdit Int 40 | -------------------------------------------------------------------------------- /app/elm/Native/Markdown.js: -------------------------------------------------------------------------------- 1 | var _Libbum$oration$Native_Markdown = function() { 2 | 3 | 4 | // VIRTUAL-DOM WIDGETS 5 | 6 | function toHtml(options, factList, rawMarkdown) { 7 | var model = { 8 | options: options, 9 | markdown: rawMarkdown 10 | }; 11 | return _elm_lang$virtual_dom$Native_VirtualDom.custom(factList, model, implementation); 12 | } 13 | 14 | 15 | // WIDGET IMPLEMENTATION 16 | 17 | var implementation = { 18 | render: render, 19 | diff: diff 20 | }; 21 | 22 | function render(model) { 23 | var html = parse(model.markdown, formatOptions(model.options)); 24 | var div = document.createElement('div'); 25 | div.innerHTML = html; 26 | return div; 27 | } 28 | 29 | function diff(a, b) { 30 | if (a.model.markdown === b.model.markdown && a.model.options === b.model.options) { 31 | return null; 32 | } 33 | 34 | return { 35 | applyPatch: applyPatch, 36 | data: parse(b.model.markdown, formatOptions(b.model.options)) 37 | }; 38 | } 39 | 40 | function applyPatch(domNode, data) { 41 | domNode.innerHTML = data; 42 | return domNode; 43 | } 44 | 45 | 46 | // ACTUAL MARKDOWN PARSER 47 | 48 | var parse = function() { 49 | // catch the parser object regardless of the outer environment. 50 | // (ex. a CommonJS module compatible environment.) 51 | // note that this depends on markdown-it's implementation of environment detection. 52 | var module = {}; 53 | var exports = module.exports = {}; 54 | 55 | // markdown-it 8.4.0 https://github.com//markdown-it/markdown-it @license MIT 56 | // markdown-it-katex 2.0.3 https://github.com/waylonflinn/markdown-it-katex/ @license MIT 57 | return function(text, options) { 58 | const md = require('markdown-it')(options.preset, options), 59 | mk = require('markdown-it-katex'); 60 | 61 | md.use(mk); 62 | return md.render(text); 63 | }; 64 | }(); 65 | 66 | 67 | // FORMAT OPTIONS FOR PARSER IMPLEMENTATION 68 | 69 | function formatOptions(options) { 70 | 71 | function toHighlight(code, lang) { 72 | try { 73 | if (!lang && options.defaultHighlighting.ctor && options.defaultHighlighting.ctor === 'Just') { 74 | lang = options.defaultHighlighting._0; 75 | } 76 | 77 | if (typeof Prism !== 'undefined' && lang && Prism.languages[lang]) { 78 | return Prism.highlight(code, Prism.languages[lang]); 79 | } 80 | 81 | return code; 82 | } catch (e) { 83 | return code; 84 | } 85 | } 86 | 87 | options.highlight = toHighlight; 88 | 89 | // assign all 'Just' values and delete all 'Nothing' values 90 | for (var key in options) { 91 | var val = options[key]; 92 | if (!val) { 93 | continue; 94 | } 95 | if (val.ctor === 'Just') { 96 | options[key] = val._0; 97 | } else if (val.ctor === 'Nothing') { 98 | delete options[key]; 99 | } 100 | } 101 | 102 | if (options.githubFlavored) { 103 | options.preset = 'default'; 104 | } else { 105 | options.preset = 'commonmark'; 106 | } 107 | 108 | if (options.quotes) { 109 | options.quotes = [options.quotes.doubleLeft, options.quotes.doubleRight, options.quotes.singleLeft, options.quotes.singleRight]; 110 | } 111 | 112 | return options; 113 | } 114 | 115 | 116 | // EXPORTS 117 | 118 | return { 119 | toHtml: F3(toHtml) 120 | }; 121 | 122 | }(); 123 | -------------------------------------------------------------------------------- /app/elm/Ports.elm: -------------------------------------------------------------------------------- 1 | port module Ports exposing (email, name, preview, setEmail, setName, setPreview, setUrl, title, url) 2 | 3 | {- port for listening for document title from JavaScript -} 4 | 5 | 6 | port title : (String -> msg) -> Sub msg 7 | 8 | 9 | 10 | {- Get name from localStorage -} 11 | 12 | 13 | port name : (Maybe String -> msg) -> Sub msg 14 | 15 | 16 | port setName : Maybe String -> Cmd msg 17 | 18 | 19 | 20 | {- Get email from localStorage -} 21 | 22 | 23 | port email : (Maybe String -> msg) -> Sub msg 24 | 25 | 26 | port setEmail : Maybe String -> Cmd msg 27 | 28 | 29 | 30 | {- Get url from localStorage -} 31 | 32 | 33 | port url : (Maybe String -> msg) -> Sub msg 34 | 35 | 36 | port setUrl : Maybe String -> Cmd msg 37 | 38 | 39 | 40 | {- Get preview option from localStorage -} 41 | 42 | 43 | port preview : (Maybe String -> msg) -> Sub msg 44 | 45 | 46 | port setPreview : Maybe String -> Cmd msg 47 | -------------------------------------------------------------------------------- /app/elm/Request/Comment.elm: -------------------------------------------------------------------------------- 1 | module Request.Comment exposing (comments, count, delete, dislike, edit, like, post) 2 | 3 | import Data.Comment as Comment exposing (Comment, Edited, Inserted) 4 | import Data.User exposing (Identity) 5 | import Http 6 | import HttpBuilder 7 | import Json.Decode as Decode 8 | import Models exposing (Model) 9 | import Navigation exposing (Location) 10 | 11 | 12 | {-| Request the number of comments for a given post 13 | -} 14 | count : Location -> Http.Request String 15 | count location = 16 | "/oration/count" 17 | |> HttpBuilder.get 18 | |> HttpBuilder.withQueryParams [ ( "url", location.pathname ) ] 19 | |> HttpBuilder.withExpect Http.expectString 20 | |> HttpBuilder.toRequest 21 | 22 | 23 | {-| We want to override the default post behaviour of the form and send this data seemlessly to the backend 24 | -} 25 | post : Model -> Http.Request Inserted 26 | post model = 27 | let 28 | --These values will always be sent 29 | body = 30 | [ ( "comment", model.comment ) 31 | , ( "title", model.title ) 32 | , ( "path", model.post.pathname ) 33 | ] 34 | 35 | --User details are only sent if they exist 36 | expect = 37 | Comment.insertDecoder 38 | |> Http.expectJson 39 | in 40 | "/oration" 41 | |> HttpBuilder.post 42 | |> HttpBuilder.withUrlEncodedBody 43 | (prependMaybe body "parent" (Maybe.map toString model.parent) 44 | ++ prependMaybe body "name" model.user.name 45 | ++ prependMaybe body "email" model.user.email 46 | ++ prependMaybe body "url" model.user.url 47 | ) 48 | |> HttpBuilder.withExpect expect 49 | |> HttpBuilder.toRequest 50 | 51 | 52 | 53 | {- Request to edit a given comment -} 54 | 55 | 56 | edit : Int -> Model -> Http.Request Edited 57 | edit id model = 58 | let 59 | --Only the comment itself and possibly author details can be edited 60 | --We need to send new author info, but the old hash to verify the edit 61 | body = 62 | [ ( "comment", model.comment ) ] 63 | 64 | --We post here since we are only sending a few pieces of information 65 | --See https://stormpath.com/blog/put-or-post 66 | expect = 67 | Comment.editDecoder 68 | |> Http.expectJson 69 | in 70 | "/oration/edit" 71 | |> HttpBuilder.post 72 | |> HttpBuilder.withHeader "x-auth-hash" model.user.identity 73 | |> HttpBuilder.withQueryParams [ ( "id", toString id ) ] 74 | |> HttpBuilder.withUrlEncodedBody 75 | (prependMaybe body "name" model.user.name 76 | ++ prependMaybe body "email" model.user.email 77 | ++ prependMaybe body "url" model.user.url 78 | ) 79 | |> HttpBuilder.withExpect expect 80 | |> HttpBuilder.toRequest 81 | 82 | 83 | 84 | {- Request to like a given comment -} 85 | 86 | 87 | like : Int -> Http.Request Int 88 | like id = 89 | "/oration/like" 90 | |> HttpBuilder.post 91 | |> HttpBuilder.withQueryParams [ ( "id", toString id ) ] 92 | |> HttpBuilder.withExpect (Http.expectStringResponse (\response -> Ok (Result.withDefault -1 (String.toInt response.body)))) 93 | |> HttpBuilder.toRequest 94 | 95 | 96 | 97 | {- Request to dislike a given comment -} 98 | 99 | 100 | dislike : Int -> Http.Request Int 101 | dislike id = 102 | "/oration/dislike" 103 | |> HttpBuilder.post 104 | |> HttpBuilder.withQueryParams [ ( "id", toString id ) ] 105 | |> HttpBuilder.withExpect (Http.expectStringResponse (\response -> Ok (Result.withDefault -1 (String.toInt response.body)))) 106 | |> HttpBuilder.toRequest 107 | 108 | 109 | 110 | {- Request to delete a given comment -} 111 | 112 | 113 | delete : Int -> Identity -> Http.Request Int 114 | delete id identity = 115 | "/oration/delete" 116 | |> HttpBuilder.delete 117 | |> HttpBuilder.withHeader "x-auth-hash" identity 118 | |> HttpBuilder.withQueryParams [ ( "id", toString id ) ] 119 | |> HttpBuilder.withExpect (Http.expectStringResponse (\response -> Ok (Result.withDefault -1 (String.toInt response.body)))) 120 | |> HttpBuilder.toRequest 121 | 122 | 123 | {-| Request the comments for the current url 124 | -} 125 | comments : Location -> Http.Request (List Comment) 126 | comments location = 127 | let 128 | expect = 129 | Decode.list Comment.decoder 130 | |> Decode.field "comments" 131 | |> Http.expectJson 132 | in 133 | "/oration/comments" 134 | |> HttpBuilder.get 135 | |> HttpBuilder.withQueryParams [ ( "url", location.pathname ) ] 136 | |> HttpBuilder.withExpect expect 137 | |> HttpBuilder.toRequest 138 | 139 | 140 | {-| Adds a pair to the list so long as there is data in the second 141 | -} 142 | prependMaybe : List ( a, a ) -> a -> Maybe a -> List ( a, a ) 143 | prependMaybe list id maybe = 144 | case maybe of 145 | Just value -> 146 | ( id, value ) :: list 147 | 148 | Nothing -> 149 | list 150 | -------------------------------------------------------------------------------- /app/elm/Request/Init.elm: -------------------------------------------------------------------------------- 1 | module Request.Init exposing (hashes) 2 | 3 | import Data.Init as Init exposing (Init) 4 | import Http 5 | import HttpBuilder 6 | 7 | 8 | {-| Request a hash of the users IP and the hash of the blog author (if set) 9 | -} 10 | hashes : Http.Request Init 11 | hashes = 12 | let 13 | expect = 14 | Init.decoder 15 | |> Http.expectJson 16 | in 17 | "/oration/init" 18 | |> HttpBuilder.get 19 | |> HttpBuilder.withExpect expect 20 | |> HttpBuilder.toRequest 21 | -------------------------------------------------------------------------------- /app/elm/Style.elm: -------------------------------------------------------------------------------- 1 | module Style exposing (..) 2 | 3 | import Css exposing (..) 4 | import Css.Elements exposing (button, img, input, label, li, p, textarea, typeSelector) 5 | import Css.Namespace exposing (namespace) 6 | import Html.CssHelpers exposing (withNamespace) 7 | 8 | 9 | type OrationClasses 10 | = Submit 11 | | Footer 12 | | Response 13 | | Thread 14 | | BlogAuthor 15 | | Identicon 16 | | Author 17 | | Deleted 18 | | Date 19 | | Comment 20 | | Content 21 | | Form 22 | | Spacer 23 | | User 24 | | Control 25 | | Block 26 | | LeftMargin10 27 | | Hidden 28 | | Toggle 29 | | Votes 30 | 31 | 32 | type OrationIds 33 | = Oration 34 | | OrationComments 35 | | OrationForm 36 | | OrationReplyForm 37 | | OrationPreviewCheck 38 | | OrationCommentPreview 39 | 40 | 41 | orationNamespace : Html.CssHelpers.Namespace String class id msg 42 | orationNamespace = 43 | withNamespace "oration" 44 | 45 | 46 | 47 | {- Colors -} 48 | 49 | 50 | primaryColor : Color 51 | primaryColor = 52 | hex "6496c8" 53 | 54 | 55 | hoverColor : Color 56 | hoverColor = 57 | hex "346392" 58 | 59 | 60 | activeColor : Color 61 | activeColor = 62 | hex "27496d" 63 | 64 | 65 | css : Stylesheet 66 | css = 67 | (stylesheet << namespace orationNamespace.name) 68 | [ class Submit 69 | [ color (hex "fff") 70 | , textShadow3 (px -2) (px -2) hoverColor 71 | , backgroundColor (hex "ff9664") 72 | , backgroundImage (linearGradient2 toTop (stop primaryColor) (stop hoverColor) []) 73 | , cursor pointer 74 | , borderRadius (px 15) 75 | , border zero 76 | , boxShadow6 inset zero zero zero (px 1) activeColor 77 | , hover [ property "box-shadow" "inset 0 0 0 1px #27496d,0 5px 15px #193047" ] 78 | , active [ property "box-shadow" "inset 0 0 0 1px #27496d,0 5px 30px #193047" ] 79 | , disabled [ color (hex "ccc") ] 80 | , padding2 (px 10) (px 20) 81 | , marginLeft (px 20) 82 | ] 83 | , class Footer 84 | [ children 85 | [ button 86 | [ clickableStyle 87 | , border3 (px 1) solid primaryColor 88 | , borderRadius (px 15) 89 | , marginBottom (px 10) 90 | , marginRight (px 10) 91 | , fontSize (pt 10) 92 | , disabled 93 | [ cursor default 94 | , color (hex "ccc") 95 | ] 96 | ] 97 | ] 98 | ] 99 | , class Votes 100 | [ color primaryColor 101 | , marginRight (px 10) 102 | , children 103 | [ button 104 | [ clickableStyle 105 | , border3 (px 1) solid primaryColor 106 | , borderRadius (px 15) 107 | , fontSize (pt 10) 108 | , disabled 109 | [ cursor default 110 | , color (hex "ccc") 111 | ] 112 | ] 113 | ] 114 | ] 115 | , class Response 116 | [ paddingLeft (px 20) 117 | , border3 (px 1) solid (hex "F00") 118 | ] 119 | , class Control 120 | [ float right 121 | , display block 122 | , marginBottom (px 5) 123 | ] 124 | , class Block 125 | [ display block 126 | ] 127 | , class Hidden 128 | [ display none 129 | ] 130 | , class Toggle 131 | [ clickableStyle 132 | , border zero 133 | ] 134 | , class BlogAuthor 135 | [ backgroundColor (rgba 0 0 0 0.03) ] 136 | , class Author 137 | [ fontWeight bold 138 | , color (hex "555") 139 | ] 140 | , class Deleted 141 | [ fontStyle italic 142 | , color (hex "555") 143 | , padding2 zero (px 6) 144 | , marginBottom (px 10) 145 | , display inlineBlock 146 | ] 147 | , each [ class Date, class Spacer ] 148 | [ color (hex "666") 149 | , fontWeight normal 150 | , textShadow none 151 | ] 152 | , class Spacer 153 | [ padding2 zero (px 6) 154 | ] 155 | , class Content 156 | [ padding (px 0) 157 | , children 158 | [ p 159 | [ firstOfType 160 | [ marginTop (em 0.5) ] 161 | , lastOfType 162 | [ marginBottom (em 0.25) ] 163 | ] 164 | ] 165 | , descendants 166 | [ img 167 | [ maxWidth (pct 100) 168 | ] 169 | , Css.Elements.pre 170 | [ overflowX auto 171 | ] 172 | ] 173 | ] 174 | , class Comment 175 | [ padding (px 0) ] 176 | , class Identicon 177 | [ display inlineBlock 178 | , verticalAlign middle 179 | , marginRight (px 10) 180 | ] 181 | , class LeftMargin10 182 | [ marginLeft (px 10) 183 | ] 184 | , typeSelector "input[type=\"checkbox\"]" 185 | [ adjacentSiblings 186 | [ label 187 | [ lastChild 188 | [ marginBottom zero ] 189 | , before 190 | [ display inlineBlock 191 | , property "content" "''" 192 | , width (px 20) 193 | , height (px 20) 194 | , border3 (px 1) solid (rgba 0 0 0 0.5) 195 | , position absolute 196 | , left (px 5) 197 | , top zero 198 | , opacity (num 0.6) 199 | , property "-webkit-transition" "all .12s, border-color .08s" 200 | , property "transition" "all .12s, border-color .08s" 201 | ] 202 | , display inlineBlock 203 | , position relative 204 | , paddingLeft (px 30) 205 | , cursor pointer 206 | , property "-webkit-user-select" "none" 207 | , property "-moz-user-select" "none" 208 | , property "-ms-user-select" "none" 209 | ] 210 | ] 211 | , display none 212 | , checked 213 | [ adjacentSiblings 214 | [ label 215 | [ before 216 | [ width (px 10) 217 | , top (px -5) 218 | , left (px 10) 219 | , borderRadius zero 220 | , opacity (int 1) 221 | , borderTopColor transparent 222 | , borderLeftColor transparent 223 | , transform (rotate (deg 45)) 224 | , property "-webkit-transform" "rotate(45deg)" 225 | ] 226 | ] 227 | ] 228 | ] 229 | ] 230 | , class Form 231 | [ children 232 | [ textarea [ inputStyle ] 233 | ] 234 | , margin3 zero auto (px 10) 235 | , float left 236 | ] 237 | , class User 238 | [ paddingTop (px 2) 239 | , paddingBottom (px 5) 240 | , children 241 | [ input 242 | [ inputStyle 243 | , width (px 162) 244 | ] 245 | ] 246 | ] 247 | , Css.Elements.code 248 | [ fontSize (px 14) 249 | ] 250 | , id OrationComments 251 | [ padding zero 252 | , children 253 | [ li 254 | [ firstChild 255 | [ border zero 256 | ] 257 | ] 258 | ] 259 | ] 260 | , li 261 | [ listStyleType none 262 | , borderTop3 (px 1) solid (rgba 0 0 0 0.2) 263 | , paddingTop (px 5) 264 | ] 265 | , id Oration 266 | [ width (px 597) 267 | ] 268 | ] 269 | 270 | 271 | inputStyle : Style 272 | inputStyle = 273 | batch 274 | [ padding2 (em 0.3) (px 10) 275 | , borderRadius (px 3) 276 | , lineHeight (em 1.4) 277 | , border3 (px 1) solid (rgba 0 0 0 0.2) 278 | , boxShadow4 zero (px 1) (px 2) (rgba 0 0 0 0.1) 279 | ] 280 | 281 | 282 | clickableStyle : Style 283 | clickableStyle = 284 | batch 285 | [ backgroundColor (rgba 0 0 0 0) 286 | , color primaryColor 287 | , cursor pointer 288 | , outline zero 289 | , hover [ borderColor hoverColor, color hoverColor ] 290 | , active [ borderColor activeColor, color activeColor ] 291 | ] 292 | -------------------------------------------------------------------------------- /app/elm/Stylesheets.elm: -------------------------------------------------------------------------------- 1 | port module Stylesheets exposing (..) 2 | 3 | import Css.File exposing (CssCompilerProgram, CssFileStructure) 4 | import Css.Normalize 5 | import Style 6 | 7 | 8 | {- Stylesheets -} 9 | 10 | 11 | port files : CssFileStructure -> Cmd msg 12 | 13 | 14 | fileStructure : CssFileStructure 15 | fileStructure = 16 | Css.File.toFileStructure 17 | [ ( "oration.css", Css.File.compile [ Css.Normalize.css, Style.css ] ) ] 18 | 19 | 20 | main : CssCompilerProgram 21 | main = 22 | Css.File.compiler files fileStructure 23 | -------------------------------------------------------------------------------- /app/elm/Time/DateTime/Distance.elm: -------------------------------------------------------------------------------- 1 | module Time.DateTime.Distance exposing (inWords) 2 | 3 | import Time.DateTime as DT 4 | 5 | 6 | type Interval 7 | = Second 8 | | Minute 9 | | Hour 10 | | Day 11 | | Month 12 | | Year 13 | 14 | 15 | type Distance 16 | = LessThanXSeconds Int 17 | | HalfAMinute 18 | | LessThanXMinutes Int 19 | | XMinutes Int 20 | | AboutXHours Int 21 | | XDays Int 22 | | AboutXMonths Int 23 | | XMonths Int 24 | | AboutXYears Int 25 | | OverXYears Int 26 | | AlmostXYears Int 27 | 28 | 29 | minutes_in_day : number 30 | minutes_in_day = 31 | 1440 32 | 33 | 34 | minutes_in_almost_two_days : number 35 | minutes_in_almost_two_days = 36 | 2520 37 | 38 | 39 | minutes_in_month : number 40 | minutes_in_month = 41 | 43200 42 | 43 | 44 | minutes_in_two_months : number 45 | minutes_in_two_months = 46 | 86400 47 | 48 | 49 | inWords : DT.DateTime -> DT.DateTime -> String 50 | inWords first second = 51 | let 52 | distance = 53 | calculateDistance <| DT.delta first second 54 | in 55 | fromDistance distance 56 | 57 | 58 | upToOneDay : Int -> Distance 59 | upToOneDay minutes = 60 | let 61 | hours = 62 | round <| toFloat minutes / 60 63 | in 64 | AboutXHours hours 65 | 66 | 67 | upToOneMonth : Int -> Distance 68 | upToOneMonth minutes = 69 | let 70 | days = 71 | round <| toFloat minutes / minutes_in_day 72 | in 73 | XDays days 74 | 75 | 76 | upToTwoMonths : Int -> Distance 77 | upToTwoMonths minutes = 78 | let 79 | months = 80 | round <| toFloat minutes / minutes_in_month 81 | in 82 | AboutXMonths months 83 | 84 | 85 | upToOneYear : Int -> Distance 86 | upToOneYear minutes = 87 | let 88 | nearestMonth = 89 | round <| toFloat minutes / minutes_in_month 90 | in 91 | XMonths nearestMonth 92 | 93 | 94 | calculateDistance : DT.DateTimeDelta -> Distance 95 | calculateDistance delta = 96 | let 97 | minutes = 98 | delta.minutes 99 | 100 | months = 101 | delta.months 102 | 103 | years = 104 | delta.years 105 | in 106 | if minutes == 0 then 107 | LessThanXMinutes 1 108 | else if minutes < 2 then 109 | XMinutes minutes 110 | else if minutes < 45 then 111 | -- 2 mins up to 0.75 hrs 112 | XMinutes minutes 113 | else if minutes < 90 then 114 | -- 0.75 hrs up to 1.5 hrs 115 | AboutXHours 1 116 | else if minutes < minutes_in_day then 117 | -- 1.5 hrs up to 24 hrs 118 | upToOneDay minutes 119 | else if minutes < minutes_in_almost_two_days then 120 | -- 1 day up to 1.75 days 121 | XDays 1 122 | else if minutes < minutes_in_month then 123 | -- 1.75 days up to 30 days 124 | upToOneMonth minutes 125 | else if minutes < minutes_in_two_months then 126 | -- 1 month up to 2 months 127 | upToTwoMonths minutes 128 | else if months < 12 then 129 | -- 2 months up to 12 months 130 | upToOneYear minutes 131 | else 132 | -- 1 year up to max Date 133 | let 134 | monthsSinceStartOfYear = 135 | months % 12 136 | in 137 | if monthsSinceStartOfYear < 3 then 138 | -- N years up to 1 years 3 months 139 | AboutXYears years 140 | else if monthsSinceStartOfYear < 9 then 141 | -- N years 3 months up to N years 9 months 142 | OverXYears years 143 | else 144 | -- N years 9 months up to N year 12 months 145 | AlmostXYears <| years + 1 146 | 147 | 148 | fromDistance : Distance -> String 149 | fromDistance distance = 150 | case distance of 151 | LessThanXSeconds i -> 152 | circa "less than" Second i 153 | 154 | HalfAMinute -> 155 | "half a minute" 156 | 157 | LessThanXMinutes i -> 158 | circa "less than" Minute i 159 | 160 | XMinutes i -> 161 | exact Minute i 162 | 163 | AboutXHours i -> 164 | circa "about" Hour i 165 | 166 | XDays i -> 167 | exact Day i 168 | 169 | AboutXMonths i -> 170 | circa "about" Month i 171 | 172 | XMonths i -> 173 | exact Month i 174 | 175 | AboutXYears i -> 176 | circa "about" Year i 177 | 178 | OverXYears i -> 179 | circa "over" Year i 180 | 181 | AlmostXYears i -> 182 | circa "almost" Year i 183 | 184 | 185 | formatInterval : Interval -> String 186 | formatInterval = 187 | String.toLower << toString 188 | 189 | 190 | singular : Interval -> String 191 | singular interval = 192 | case interval of 193 | Minute -> 194 | "a " ++ formatInterval interval 195 | 196 | _ -> 197 | "1 " ++ formatInterval interval 198 | 199 | 200 | circa : String -> Interval -> Int -> String 201 | circa prefix interval i = 202 | case i of 203 | 1 -> 204 | prefix ++ " " ++ singular interval ++ " ago" 205 | 206 | _ -> 207 | prefix ++ " " ++ toString i ++ " " ++ formatInterval interval ++ "s ago" 208 | 209 | 210 | exact : Interval -> Int -> String 211 | exact interval i = 212 | case i of 213 | 1 -> 214 | "1 " ++ formatInterval interval ++ " ago" 215 | 216 | _ -> 217 | toString i ++ " " ++ formatInterval interval ++ "s ago" 218 | -------------------------------------------------------------------------------- /app/elm/Update.elm: -------------------------------------------------------------------------------- 1 | module Update exposing (currentDate, subscriptions, update) 2 | 3 | import Data.Comment as Comment 4 | import Data.User exposing (getIdentity) 5 | import Http 6 | import Maybe.Extra exposing ((?)) 7 | import Models exposing (Model, Status(..)) 8 | import Msg exposing (Msg(..)) 9 | import Ports 10 | import Request.Comment 11 | import Task 12 | import Time exposing (minute) 13 | import Time.DateTime exposing (DateTime) 14 | import Util exposing (delay) 15 | 16 | 17 | update : Msg -> Model -> ( Model, Cmd Msg ) 18 | update msg model = 19 | case msg of 20 | UpdateComment comment -> 21 | { model | comment = comment } ! [] 22 | 23 | UpdateName name -> 24 | let 25 | user = 26 | model.user 27 | in 28 | { model | user = { user | name = name } } ! [] 29 | 30 | UpdateEmail email -> 31 | let 32 | user = 33 | model.user 34 | in 35 | { model | user = { user | email = email } } ! [] 36 | 37 | UpdateUrl url -> 38 | let 39 | user = 40 | model.user 41 | in 42 | { model | user = { user | url = url } } ! [] 43 | 44 | UpdatePreview -> 45 | let 46 | user = 47 | model.user 48 | in 49 | { model | user = { user | preview = not model.user.preview } } ! [] 50 | 51 | SetPreview strPreview -> 52 | let 53 | user = 54 | model.user 55 | 56 | preview_ = 57 | dumbDecode strPreview 58 | in 59 | { model | user = { user | preview = preview_ } } ! [] 60 | 61 | StoreUser -> 62 | model 63 | ! [ Cmd.batch 64 | [ Ports.setName model.user.name 65 | , Ports.setEmail model.user.email 66 | , Ports.setUrl model.user.url 67 | , Ports.setPreview (Just <| toString model.user.preview) 68 | ] 69 | ] 70 | 71 | Count (Ok strCount) -> 72 | let 73 | intCount = 74 | case String.toInt strCount of 75 | Ok val -> 76 | val 77 | 78 | Err _ -> 79 | 0 80 | in 81 | { model | count = intCount } ! [] 82 | 83 | Count (Err error) -> 84 | { model | debug = toString error } ! [] 85 | 86 | Post location -> 87 | { model | post = location } ! [] 88 | 89 | Title value -> 90 | { model | title = value } ! [] 91 | 92 | PostComment -> 93 | model 94 | ! [ let 95 | postReq = 96 | Request.Comment.post model 97 | |> Http.toTask 98 | in 99 | Task.attempt PostConfirm postReq 100 | ] 101 | 102 | PostConfirm (Ok result) -> 103 | let 104 | user = 105 | model.user 106 | 107 | author = 108 | getIdentity user 109 | 110 | comments = 111 | Comment.insertNew result ( model.comment, author, model.now, model.comments ) 112 | in 113 | { model 114 | | comment = "" 115 | , parent = Nothing 116 | , count = model.count + 1 117 | , debug = toString result 118 | , comments = comments 119 | , status = Commenting 120 | , user = { user | identity = author } 121 | } 122 | ! [ timeoutEdits model.editTimeout result.id ] 123 | 124 | PostConfirm (Err error) -> 125 | { model | debug = toString error } ! [] 126 | 127 | Hashes (Ok result) -> 128 | let 129 | user = 130 | model.user 131 | in 132 | { model 133 | | user = 134 | { user 135 | | iphash = result.userIp 136 | , identity = getIdentity user 137 | } 138 | , blogAuthor = result.blogAuthor ? "" 139 | , editTimeout = result.editTimeout 140 | } 141 | ! [] 142 | 143 | Hashes (Err error) -> 144 | { model | debug = toString error } ! [] 145 | 146 | Comments (Ok result) -> 147 | let 148 | count = 149 | Comment.count result 150 | in 151 | { model 152 | | comments = result 153 | , count = count 154 | } 155 | ! [] 156 | 157 | Comments (Err error) -> 158 | { model | debug = toString error } ! [] 159 | 160 | GetDate _ -> 161 | model ! [ Task.perform NewDate currentDate ] 162 | 163 | NewDate date -> 164 | { model | now = date } ! [] 165 | 166 | CommentReply id -> 167 | let 168 | value = 169 | if model.parent == Just id then 170 | Nothing 171 | else 172 | Just id 173 | 174 | status = 175 | if model.parent == Just id then 176 | Commenting 177 | else 178 | Replying 179 | in 180 | { model 181 | | parent = value 182 | , status = status 183 | } 184 | ! [] 185 | 186 | CommentEdit id -> 187 | let 188 | value = 189 | if model.parent == Just id then 190 | Nothing 191 | else 192 | Just id 193 | 194 | status = 195 | if model.parent == Just id then 196 | Commenting 197 | else 198 | Editing 199 | 200 | comment = 201 | if model.parent == Just id then 202 | "" 203 | else 204 | Comment.getText id model.comments 205 | in 206 | { model 207 | | parent = value 208 | , comment = comment 209 | , status = status 210 | } 211 | ! [ timeoutEdits model.editTimeout id ] 212 | 213 | SendEdit id -> 214 | model 215 | ! [ let 216 | postReq = 217 | Request.Comment.edit id model 218 | |> Http.toTask 219 | in 220 | Task.attempt EditConfirm postReq 221 | ] 222 | 223 | EditConfirm (Ok result) -> 224 | let 225 | user = 226 | model.user 227 | 228 | comments = 229 | Comment.update result model.comments 230 | in 231 | { model 232 | | debug = toString result 233 | , status = Commenting 234 | , comments = comments 235 | , comment = "" 236 | , parent = Nothing 237 | , user = { user | identity = getIdentity user } 238 | } 239 | ! [ timeoutEdits model.editTimeout result.id ] 240 | 241 | EditConfirm (Err error) -> 242 | { model | debug = toString error } ! [] 243 | 244 | CommentDelete id -> 245 | model 246 | ! [ let 247 | postReq = 248 | Request.Comment.delete id model.user.identity 249 | |> Http.toTask 250 | in 251 | Task.attempt DeleteConfirm postReq 252 | ] 253 | 254 | DeleteConfirm (Ok result) -> 255 | let 256 | comments = 257 | Comment.delete result model.comments 258 | in 259 | { model 260 | | debug = toString result 261 | , comments = comments 262 | } 263 | ! [] 264 | 265 | DeleteConfirm (Err error) -> 266 | { model | debug = toString error } ! [] 267 | 268 | CommentLike id -> 269 | model 270 | ! [ let 271 | postReq = 272 | Request.Comment.like id 273 | |> Http.toTask 274 | in 275 | Task.attempt LikeConfirm postReq 276 | ] 277 | 278 | LikeConfirm (Ok result) -> 279 | let 280 | comments = 281 | Comment.like result model.comments 282 | in 283 | { model 284 | | debug = toString result 285 | , comments = comments 286 | } 287 | ! [] 288 | 289 | LikeConfirm (Err error) -> 290 | let 291 | comments = 292 | case error of 293 | Http.BadStatus status -> 294 | Comment.disableVote (Result.withDefault -1 (String.toInt status.body)) model.comments 295 | 296 | _ -> 297 | model.comments 298 | 299 | print = 300 | case error of 301 | Http.BadStatus status -> 302 | toString status.status ++ ", " ++ status.body 303 | 304 | _ -> 305 | toString error 306 | in 307 | { model 308 | | debug = print 309 | , comments = comments 310 | } 311 | ! [] 312 | 313 | CommentDislike id -> 314 | model 315 | ! [ let 316 | postReq = 317 | Request.Comment.dislike id 318 | |> Http.toTask 319 | in 320 | Task.attempt DislikeConfirm postReq 321 | ] 322 | 323 | DislikeConfirm (Ok result) -> 324 | let 325 | comments = 326 | Comment.dislike result model.comments 327 | in 328 | { model 329 | | debug = toString result 330 | , comments = comments 331 | } 332 | ! [] 333 | 334 | DislikeConfirm (Err error) -> 335 | let 336 | comments = 337 | case error of 338 | Http.BadStatus status -> 339 | Comment.disableVote (Result.withDefault -1 (String.toInt status.body)) model.comments 340 | 341 | _ -> 342 | model.comments 343 | 344 | print = 345 | case error of 346 | Http.BadStatus status -> 347 | toString status.status ++ ", " ++ status.body 348 | 349 | _ -> 350 | toString error 351 | in 352 | { model 353 | | debug = print 354 | , comments = comments 355 | } 356 | ! [] 357 | 358 | HardenEdit id -> 359 | let 360 | comments = 361 | case model.status of 362 | Editing -> 363 | model.comments 364 | 365 | _ -> 366 | Comment.readOnly id model.comments 367 | in 368 | { model | comments = comments } ! [] 369 | 370 | ToggleCommentVisibility id -> 371 | let 372 | comments = 373 | Comment.toggleVisible id model.comments 374 | in 375 | { model | comments = comments } ! [] 376 | 377 | 378 | subscriptions : Model -> Sub Msg 379 | subscriptions model = 380 | Sub.batch 381 | [ Ports.title Title 382 | , Ports.name UpdateName 383 | , Ports.email UpdateEmail 384 | , Ports.url UpdateUrl 385 | , Ports.preview SetPreview 386 | , Time.every minute GetDate 387 | ] 388 | 389 | 390 | {-| localStorage values are always strings. We store the preview bool via toString, so this will be good enough as a decoder. 391 | -} 392 | dumbDecode : Maybe String -> Bool 393 | dumbDecode val = 394 | case val of 395 | Just decoded -> 396 | if decoded == "True" then 397 | True 398 | else 399 | False 400 | 401 | Nothing -> 402 | False 403 | 404 | 405 | currentDate : Task.Task x DateTime 406 | currentDate = 407 | Time.now |> Task.map timeToDateTime 408 | 409 | 410 | timeToDateTime : Time.Time -> DateTime 411 | timeToDateTime = 412 | Time.DateTime.fromTimestamp 413 | 414 | 415 | timeoutEdits : Float -> Int -> Cmd Msg 416 | timeoutEdits timeout id = 417 | delay (Time.second * timeout) <| HardenEdit id 418 | -------------------------------------------------------------------------------- /app/elm/Util.elm: -------------------------------------------------------------------------------- 1 | module Util exposing ((=>), delay, nothing, pair, stringToMaybe) 2 | 3 | import Html exposing (Html, text) 4 | import Process 5 | import Task 6 | import Time exposing (Time) 7 | 8 | 9 | (=>) : a -> b -> ( a, b ) 10 | (=>) = 11 | (,) 12 | 13 | 14 | {-| infixl 0 means the (=>) operator has the same precedence as (<|) and (|>), 15 | meaning you can use it at the end of a pipeline and have the precedence work out. 16 | -} 17 | infixl 0 => 18 | 19 | 20 | {-| Useful when building up a Cmd via a pipeline, and then pairing it with 21 | a model at the end. 22 | session.user 23 | |> User.Request.foo 24 | |> Task.attempt Foo 25 | |> pair { model | something = blah } 26 | -} 27 | pair : a -> b -> ( a, b ) 28 | pair first second = 29 | first => second 30 | 31 | 32 | stringToMaybe : String -> Maybe String 33 | stringToMaybe val = 34 | if String.isEmpty val then 35 | Nothing 36 | else 37 | Just val 38 | 39 | 40 | nothing : Html msg 41 | nothing = 42 | text "" 43 | 44 | 45 | 46 | {- Invoke a time delay for an action -} 47 | 48 | 49 | delay : Time -> msg -> Cmd msg 50 | delay time msg = 51 | Process.sleep time 52 | |> Task.andThen (always <| Task.succeed msg) 53 | |> Task.perform identity 54 | -------------------------------------------------------------------------------- /app/elm/View.elm: -------------------------------------------------------------------------------- 1 | module View exposing (view) 2 | 3 | import Data.Comment exposing (Comment, Responses(Responses), count) 4 | import Data.User exposing (Identity, getIdentity) 5 | import Html exposing (..) 6 | import Html.Attributes exposing (autocomplete, checked, cols, defaultValue, disabled, for, href, method, minlength, name, placeholder, rows, type_, value) 7 | import Html.Events exposing (onClick, onInput, onSubmit) 8 | import Identicon exposing (identicon) 9 | import Markdown exposing (defaultOptions) 10 | import Maybe.Extra exposing ((?), isJust, isNothing) 11 | import Models exposing (Model, Status(..)) 12 | import Msg exposing (Msg(..)) 13 | import Style 14 | import Time.DateTime.Distance exposing (inWords) 15 | import Util exposing (nothing, stringToMaybe) 16 | 17 | 18 | {- Sync up stylsheets -} 19 | 20 | 21 | { id, class, classList } = 22 | Style.orationNamespace 23 | 24 | 25 | view : Model -> Html Msg 26 | view model = 27 | let 28 | count = 29 | toString model.count 30 | ++ (if model.count /= 1 then 31 | " comments" 32 | else 33 | " comment" 34 | ) 35 | in 36 | div [ id Style.Oration ] 37 | [ h2 [] [ text count ] 38 | , commentForm model Nothing 39 | , ul [ id Style.OrationComments ] <| printComments model 40 | ] 41 | 42 | 43 | 44 | {- Comment form. Can be used as the main form or in a reply. -} 45 | 46 | 47 | commentForm : Model -> Maybe Int -> Html Msg 48 | commentForm model commentId = 49 | let 50 | -- Even though we have model.user.identity, this is a semi-persistent copy 51 | -- for editing and deleting authorisation. Here, we want up-to-date identicons 52 | identity = 53 | getIdentity model.user 54 | 55 | name_ = 56 | model.user.name ? "" 57 | 58 | email_ = 59 | model.user.email ? "" 60 | 61 | url_ = 62 | model.user.url ? "" 63 | 64 | textAreaValue = 65 | case model.status of 66 | Commenting -> 67 | if isNothing model.parent then 68 | model.comment 69 | else 70 | "" 71 | 72 | _ -> 73 | model.comment 74 | 75 | formID = 76 | case model.status of 77 | Commenting -> 78 | Style.OrationForm 79 | 80 | _ -> 81 | Style.OrationReplyForm 82 | 83 | formDisable = 84 | if isNothing commentId && isJust model.parent then 85 | True 86 | else 87 | False 88 | 89 | buttonDisable = 90 | if formDisable then 91 | True 92 | else 93 | setButtonDisabled model.comment 94 | 95 | submitText = 96 | if isNothing commentId then 97 | "Comment" 98 | --The main form is never a reply or update 99 | else 100 | case model.status of 101 | Commenting -> 102 | "Comment" 103 | 104 | Editing -> 105 | "Update" 106 | 107 | Replying -> 108 | "Reply" 109 | 110 | submitCmd = 111 | case model.status of 112 | Editing -> 113 | SendEdit (commentId ? -1) 114 | 115 | _ -> 116 | PostComment 117 | 118 | preview = 119 | if formDisable then 120 | nothing 121 | else 122 | Markdown.toHtmlWith options [ id Style.OrationCommentPreview ] <| 123 | markdownContent model.comment model.user.preview 124 | in 125 | Html.form [ method "post", id formID, class [ Style.Form ], onSubmit submitCmd ] 126 | [ textarea 127 | [ name "comment" 128 | , placeholder "Write a comment here (min 3 characters)." 129 | , value textAreaValue 130 | , minlength 3 131 | , cols 80 132 | , rows 4 133 | , onInput UpdateComment 134 | , disabled formDisable 135 | , class [ Style.Block ] 136 | ] 137 | [] 138 | , div [ class [ Style.User ] ] 139 | [ span [ class [ Style.Identicon, Style.LeftMargin10 ] ] [ identicon "25px" identity ] 140 | , input [ type_ "text", name "name", placeholder "Name (optional)", defaultValue name_, autocomplete True, onInput (\name -> UpdateName <| stringToMaybe name) ] [] 141 | , input [ type_ "email", name "email", placeholder "Email (optional)", defaultValue email_, autocomplete True, onInput (\email -> UpdateEmail <| stringToMaybe email) ] [] 142 | , input [ type_ "url", name "url", placeholder "Website (optional)", defaultValue url_, onInput (\url -> UpdateUrl <| stringToMaybe url) ] [] 143 | ] 144 | , div [ class [ Style.Control ] ] 145 | [ input [ type_ "checkbox", id Style.OrationPreviewCheck, checked model.user.preview, onClick UpdatePreview ] [] 146 | , label [ for (toString Style.OrationPreviewCheck) ] [ text "Preview" ] 147 | , input [ type_ "submit", class [ Style.Submit ], disabled buttonDisable, value submitText, onClick StoreUser ] [] 148 | ] 149 | , preview 150 | ] 151 | 152 | 153 | 154 | {- Only allows users to comment if their comment is longer than 3 characters -} 155 | 156 | 157 | setButtonDisabled : String -> Bool 158 | setButtonDisabled comment = 159 | if String.length comment > 3 then 160 | False 161 | else 162 | True 163 | 164 | 165 | 166 | {- Renders comments to markdown -} 167 | 168 | 169 | options : Markdown.Options 170 | options = 171 | { defaultOptions 172 | | githubFlavored = True 173 | , breaks = True 174 | , typographer = True 175 | , linkify = True 176 | } 177 | 178 | 179 | markdownContent : String -> Bool -> String 180 | markdownContent content preview = 181 | if preview then 182 | content 183 | else 184 | "" 185 | 186 | 187 | 188 | {- Format a list of comments -} 189 | 190 | 191 | printComments : Model -> List (Html Msg) 192 | printComments model = 193 | List.map (\c -> printComment c model) model.comments 194 | 195 | 196 | 197 | {- Format a single comment -} 198 | 199 | 200 | printComment : Comment -> Model -> Html Msg 201 | printComment comment model = 202 | let 203 | notDeleted = 204 | if String.isEmpty comment.text && String.isEmpty comment.hash then 205 | False 206 | else 207 | True 208 | 209 | author = 210 | comment.author ? "Anonymous" 211 | 212 | created = 213 | inWords model.now comment.created 214 | 215 | commentId = 216 | "comment-" ++ toString comment.id 217 | 218 | headerStyle = 219 | if comment.hash == model.blogAuthor && notDeleted then 220 | [ Style.Thread, Style.BlogAuthor ] 221 | else 222 | [ Style.Thread ] 223 | 224 | contentStyle = 225 | if comment.visible then 226 | [ Style.Comment ] 227 | else 228 | [ Style.Hidden ] 229 | 230 | visibleButtonText = 231 | if comment.visible then 232 | "[–]" 233 | else 234 | "[+" ++ toString (count <| List.singleton comment) ++ "]" 235 | in 236 | if notDeleted then 237 | li [ id commentId, class headerStyle ] 238 | [ span [ class [ Style.Identicon ] ] [ identicon "25px" comment.hash ] 239 | , printAuthor author 240 | , span [ class [ Style.Spacer ] ] [ text "•" ] 241 | , span [ class [ Style.Date ] ] [ text created ] 242 | , button [ class [ Style.Toggle ], onClick (ToggleCommentVisibility comment.id) ] [ text visibleButtonText ] 243 | , div [ class contentStyle ] 244 | [ Markdown.toHtmlWith options [ class [ Style.Content ] ] comment.text 245 | , printFooter model.status model.user.identity comment 246 | , replyForm comment.id model 247 | , printResponses comment.children model 248 | ] 249 | ] 250 | else 251 | li [ id commentId, class headerStyle ] 252 | [ span [ class [ Style.Deleted ] ] [ text "Deleted comment" ] 253 | , span [ class [ Style.Spacer ] ] [ text "•" ] 254 | , span [ class [ Style.Date ] ] [ text created ] 255 | , button [ class [ Style.Toggle ], onClick (ToggleCommentVisibility comment.id) ] [ text visibleButtonText ] 256 | , div [ class contentStyle ] [ printResponses comment.children model ] 257 | ] 258 | 259 | 260 | printAuthor : String -> Html Msg 261 | printAuthor author = 262 | if String.startsWith "http://" author || String.startsWith "https://" author then 263 | a [ class [ Style.Author ], href author ] [ text author ] 264 | else 265 | span [ class [ Style.Author ] ] [ text author ] 266 | 267 | 268 | printFooter : Status -> Identity -> Comment -> Html Msg 269 | printFooter status identity comment = 270 | let 271 | replyText = 272 | case status of 273 | Replying -> 274 | "close" 275 | 276 | _ -> 277 | "reply" 278 | 279 | editText = 280 | case status of 281 | Editing -> 282 | "close" 283 | 284 | _ -> 285 | "edit" 286 | 287 | replyDisabled = 288 | case status of 289 | Editing -> 290 | True 291 | 292 | _ -> 293 | False 294 | 295 | editDisabled = 296 | case status of 297 | Replying -> 298 | True 299 | 300 | _ -> 301 | False 302 | 303 | deleteDisabled = 304 | case status of 305 | Commenting -> 306 | False 307 | 308 | _ -> 309 | True 310 | 311 | votingDisabled = 312 | if comment.votable && comment.hash /= identity then 313 | False 314 | else 315 | True 316 | 317 | edit = 318 | if comment.editable then 319 | button [ onClick (CommentEdit comment.id), disabled editDisabled ] [ text editText ] 320 | else 321 | nothing 322 | 323 | delete = 324 | if comment.editable then 325 | button [ onClick (CommentDelete comment.id), disabled deleteDisabled ] [ text "delete" ] 326 | else 327 | nothing 328 | 329 | votes = 330 | if comment.votes == 0 then 331 | " " 332 | else 333 | " " ++ toString comment.votes 334 | in 335 | span [ class [ Style.Footer ] ] 336 | [ span [ class [ Style.Votes ] ] 337 | [ button [ onClick (CommentLike comment.id), disabled votingDisabled ] [ text "⮝" ] 338 | , button [ onClick (CommentDislike comment.id), disabled votingDisabled ] [ text "⮟" ] 339 | , text votes 340 | ] 341 | , button [ onClick (CommentReply comment.id), disabled replyDisabled ] [ text replyText ] 342 | , edit 343 | , delete 344 | ] 345 | 346 | 347 | printResponses : Responses -> Model -> Html Msg 348 | printResponses (Responses responses) model = 349 | if List.isEmpty responses then 350 | nothing 351 | else 352 | ul [] <| 353 | List.map (\c -> printComment c model) responses 354 | 355 | 356 | replyForm : Int -> Model -> Html Msg 357 | replyForm id model = 358 | case model.status of 359 | Commenting -> 360 | nothing 361 | 362 | _ -> 363 | case model.parent of 364 | Just val -> 365 | if id == val then 366 | commentForm model (Just id) 367 | else 368 | nothing 369 | 370 | Nothing -> 371 | nothing 372 | -------------------------------------------------------------------------------- /app/elm/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "summary": "A Rocket/Elm self hosted commenting system for static sites", 4 | "repository": "https://github.com/Libbum/oration.git", 5 | "license": "MIT", 6 | "source-directories": [ 7 | "." 8 | ], 9 | "exposed-modules": [], 10 | "native-modules": true, 11 | "dependencies": { 12 | "NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0", 13 | "elm-community/elm-time": "1.0.11 <= v < 2.0.0", 14 | "elm-community/json-extra": "2.3.0 <= v < 3.0.0", 15 | "elm-community/maybe-extra": "4.0.0 <= v < 5.0.0", 16 | "elm-lang/core": "5.1.1 <= v < 6.0.0", 17 | "elm-lang/dom": "1.1.1 <= v < 2.0.0", 18 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 19 | "elm-lang/http": "1.0.0 <= v < 2.0.0", 20 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0", 21 | "ktonon/elm-crypto": "1.1.0 <= v < 2.0.0", 22 | "lukewestby/elm-http-builder": "5.1.0 <= v < 6.0.0", 23 | "pukkamustard/elm-identicon": "3.0.0 <= v < 4.0.0", 24 | "rtfeldman/elm-css": "11.2.0 <= v < 12.0.0", 25 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0", 26 | "scottcorgan/elm-css-normalize": "1.1.9 <= v < 2.0.0" 27 | }, 28 | "elm-version": "0.18.0 <= v < 0.19.0" 29 | } 30 | -------------------------------------------------------------------------------- /app/elm/tests/Helpers/Dates.elm: -------------------------------------------------------------------------------- 1 | module Helpers.Dates exposing (..) 2 | 3 | import Fuzz exposing (Fuzzer, int, intRange) 4 | import Time.Date exposing (isLeapYear) 5 | import Time.DateTime exposing (DateTime, addDays, dateTime, zero) 6 | 7 | 8 | dateForYear : Int -> Fuzzer DateTime 9 | dateForYear year = 10 | let 11 | daysUpper = 12 | if isLeapYear year then 13 | 365 14 | else 15 | 364 16 | in 17 | intRange 0 daysUpper 18 | |> Fuzz.map (\days -> addDays days (dateTime { zero | year = year })) 19 | 20 | 21 | dateWithinYearRange : Int -> Int -> Fuzzer DateTime 22 | dateWithinYearRange lower upper = 23 | intRange lower upper 24 | |> Fuzz.andThen (\year -> dateForYear year) 25 | -------------------------------------------------------------------------------- /app/elm/tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Tests exposing (..) 2 | 3 | import Data.Comment exposing (Comment, Responses(Responses)) 4 | import Data.User exposing (User) 5 | import Expect exposing (..) 6 | import Fuzz exposing (Fuzzer, bool, int, list, maybe, string, tuple) 7 | import Helpers.Dates exposing (dateWithinYearRange) 8 | import Json.Decode as Decode 9 | import Json.Encode as Encode 10 | import Test exposing (..) 11 | import Time.DateTime exposing (DateTime) 12 | import Util exposing (..) 13 | 14 | 15 | user : Fuzzer User 16 | user = 17 | Fuzz.map5 User 18 | (maybe string) 19 | (maybe string) 20 | (maybe string) 21 | (maybe string) 22 | bool 23 | |> Fuzz.andMap string 24 | 25 | 26 | 27 | {- 28 | commentEncode : Fuzzer Comment 29 | commentEncode = 30 | Fuzz.map3 Comment 31 | string 32 | (maybe string) 33 | string 34 | 35 | 36 | 37 | commentDecode : Fuzzer Comment 38 | commentDecode = 39 | Fuzz.map5 Comment 40 | string 41 | (maybe string) 42 | string 43 | (maybe (dateNear 2017)) 44 | int 45 | |> Fuzz.andMap (list (Fuzz.constant [])) 46 | 47 | -} 48 | 49 | 50 | dateNear : Int -> Fuzzer DateTime 51 | dateNear y = 52 | dateWithinYearRange (y - 2) (y + 2) 53 | 54 | 55 | all : Test 56 | all = 57 | describe "Oration Front-end Test Suite" 58 | [ describe "Unit tests" 59 | [ test "String to Maybe String - Empty String" <| 60 | \() -> 61 | Expect.equal (stringToMaybe "") Nothing 62 | , test "String to Maybe String - String" <| 63 | \() -> 64 | Expect.equal (stringToMaybe "Something") (Just "Something") 65 | ] 66 | , describe "Fuzz tests" 67 | [ fuzz2 int (list string) "Pair generates tuples" <| 68 | \first second -> 69 | pair first second 70 | |> Expect.equal ( first, second ) 71 | ] 72 | , describe "Data.User" 73 | [ fuzz user "Serialization round trip" <| 74 | \thisUser -> 75 | thisUser 76 | |> Data.User.encode 77 | |> Decode.decodeValue Data.User.decoder 78 | |> Expect.equal (Ok thisUser) 79 | ] 80 | 81 | {- , describe "Data.Comment" 82 | [ fuzz commentDecode "Serialization Decode" <| 83 | \thisComment -> 84 | thisComment 85 | |> Decode.decodeValue Data.Comment.decoder 86 | |> Expect.equal (Ok thisComment) 87 | ] 88 | -} 89 | ] 90 | -------------------------------------------------------------------------------- /app/elm/tests/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "summary": "Test Suites", 4 | "repository": "https://github.com/Libbum/oration.git", 5 | "license": "MIT", 6 | "source-directories": [ 7 | "..", 8 | "." 9 | ], 10 | "exposed-modules": [], 11 | "native-modules": true, 12 | "dependencies": { 13 | "NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0", 14 | "elm-community/elm-test": "4.0.0 <= v < 5.0.0", 15 | "elm-community/elm-time": "1.0.11 <= v < 2.0.0", 16 | "elm-community/json-extra": "2.3.0 <= v < 3.0.0", 17 | "elm-community/maybe-extra": "4.0.0 <= v < 5.0.0", 18 | "elm-lang/core": "5.1.1 <= v < 6.0.0", 19 | "elm-lang/dom": "1.1.1 <= v < 2.0.0", 20 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 21 | "elm-lang/http": "1.0.0 <= v < 2.0.0", 22 | "elm-lang/navigation": "2.1.0 <= v < 3.0.0", 23 | "ktonon/elm-crypto": "1.1.0 <= v < 2.0.0", 24 | "lukewestby/elm-http-builder": "5.1.0 <= v < 6.0.0", 25 | "pukkamustard/elm-identicon": "3.0.0 <= v < 4.0.0", 26 | "rtfeldman/elm-css": "11.2.0 <= v < 12.0.0", 27 | "rtfeldman/elm-css-helpers": "2.1.0 <= v < 3.0.0", 28 | "scottcorgan/elm-css-normalize": "1.1.9 <= v < 2.0.0" 29 | }, 30 | "elm-version": "0.18.0 <= v < 0.19.0" 31 | } 32 | -------------------------------------------------------------------------------- /app/js/oration.js: -------------------------------------------------------------------------------- 1 | import Elm from './main'; 2 | const elmDiv = document.getElementById('comments'); 3 | if (elmDiv) { 4 | var app = Elm.Main.embed(elmDiv); 5 | 6 | function closestTitle(el) { 7 | var previous; 8 | // traverse previous elements to look for a h1 9 | while (el) { 10 | previous = el.previousElementSibling; 11 | if (previous && previous.tagName == "H1") { 12 | return previous.innerText; 13 | } 14 | el = previous; 15 | } 16 | return document.title; //If not found, use the page title 17 | } 18 | 19 | function setStore(state, location) { 20 | if (state) { 21 | localStorage.setItem(location, state); 22 | } else { 23 | localStorage.removeItem(location, state); 24 | } 25 | } 26 | 27 | var name = localStorage.getItem('orationName'); 28 | var email = localStorage.getItem('orationEmail'); 29 | var url = localStorage.getItem('orationUrl'); 30 | var preview = localStorage.getItem('orationPreview'); 31 | 32 | app.ports.title.send(closestTitle(elmDiv)); 33 | app.ports.name.send(name); 34 | app.ports.email.send(email); 35 | app.ports.url.send(url); 36 | app.ports.preview.send(preview); 37 | 38 | app.ports.setName.subscribe(function(state) { setStore(state, 'orationName'); }); 39 | app.ports.setEmail.subscribe(function(state) { setStore(state, 'orationEmail'); }); 40 | app.ports.setUrl.subscribe(function(state) { setStore(state, 'orationUrl'); }); 41 | app.ports.setPreview.subscribe(function(state) { setStore(state, 'orationPreview'); }); 42 | } 43 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oration", 3 | "version": "0.1.0", 4 | "description": "A Rocket/Elm self hosted commenting system for static sites", 5 | "author": "Tim DuBois ", 6 | "scripts": { 7 | "deploy": "brunch build --production", 8 | "stage": "brunch watch --production --stdin", 9 | "watch": "brunch watch --stdin" 10 | }, 11 | "license": "MIT", 12 | "devDependencies": { 13 | "auto-reload-brunch": "^2.7.1", 14 | "babel-brunch": "^6.1.1", 15 | "brunch": "^2.10.9", 16 | "clean-css-brunch": "^2.10.0", 17 | "css-brunch": "^2.10.0", 18 | "cssnano-brunch": "^1.1.8", 19 | "elm": "^0.18.0", 20 | "elm-brunch": "^0.9.0", 21 | "uglify-js-brunch": "^2.10.0" 22 | }, 23 | "dependencies": { 24 | "elm-css-brunch": "^0.2.0", 25 | "markdown-it": "^8.4.0", 26 | "markdown-it-katex": "^2.0.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Libbum/oration/41d7f0136df1b2d165dc194604c2fb1d47ddbf40/app/static/favicon.ico -------------------------------------------------------------------------------- /app/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Oration - Test Index Page 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 20 | 21 | 22 | 23 |

Index page

24 |

Here are a few posts that you might enjoy commenting on:

25 |

Post 1 - x Comments

26 |

Post 2 - x Comments

27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/static/post-1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Oration - Test Page 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | 23 | 24 |

Post 1

25 |

Here is a post. Please comment below, or go somewhere else.

26 |

Index

27 |

Post 2

28 |
29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/static/post-2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Oration - Second Test Page 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | 23 | 24 |

Post 2

25 |

Because you liked the first post so much, I wrote another.

26 |

Index

27 |

Post 1

28 |
29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /logo/logo_b.svg: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | Oration 17 | 18 | 19 | -------------------------------------------------------------------------------- /logo/logo_w.svg: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | Oration 17 | 18 | 19 | -------------------------------------------------------------------------------- /logo/logo_wbl.svg: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Oration 18 | 19 | 20 | -------------------------------------------------------------------------------- /migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Libbum/oration/41d7f0136df1b2d165dc194604c2fb1d47ddbf40/migrations/.gitkeep -------------------------------------------------------------------------------- /migrations/20170719094701_create_oration/down.sql: -------------------------------------------------------------------------------- 1 | DROP TRIGGER remove_stale_threads; 2 | DROP TABLE preferences; 3 | DROP TABLE threads; 4 | DROP TABLE comments; 5 | -------------------------------------------------------------------------------- /migrations/20170719094701_create_oration/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE preferences ( 2 | key VARCHAR PRIMARY KEY NOT NULL, 3 | value VARCHAR NOT NULL 4 | ); 5 | 6 | CREATE TABLE threads ( 7 | id INTEGER PRIMARY KEY NOT NULL, 8 | uri VARCHAR(256) UNIQUE NOT NULL, 9 | title VARCHAR(256) 10 | ); 11 | 12 | CREATE TABLE comments ( 13 | id INTEGER PRIMARY KEY NOT NULL, 14 | tid REFERENCES threads(id), 15 | parent INTEGER, 16 | created DATETIME NOT NULL, 17 | modified DATETIME, 18 | mode INTEGER NOT NULL, 19 | remote_addr VARCHAR, 20 | text VARCHAR NOT NULL, 21 | author VARCHAR, 22 | email VARCHAR, 23 | website VARCHAR, 24 | hash VARCHAR NOT NULL, 25 | likes INTEGER DEFAULT 0, 26 | dislikes INTEGER DEFAULT 0, 27 | voters BLOB 28 | ); 29 | 30 | CREATE TRIGGER remove_stale_threads AFTER DELETE ON comments BEGIN 31 | DELETE FROM threads WHERE id NOT IN (SELECT tid FROM comments); 32 | END; 33 | 34 | INSERT INTO preferences (key, value) VALUES ('session-key', '0000'); 35 | -------------------------------------------------------------------------------- /oration.yaml: -------------------------------------------------------------------------------- 1 | # _______ _______ _______ __________________ _______ _ 2 | # ( ___ )( ____ )( ___ )\__ __/\__ __/( ___ )( ( /| 3 | # | ( ) || ( )|| ( ) | ) ( ) ( | ( ) || \ ( | 4 | # | | | || (____)|| (___) | | | | | | | | || \ | | 5 | # | | | || __)| ___ | | | | | | | | || (\ \) | 6 | # | | | || (\ ( | ( ) | | | | | | | | || | \ | 7 | # | (___) || ) \ \__| ) ( | | | ___) (___| (___) || ) \ | 8 | # (_______)|/ \__/|/ \| )_( \_______/(_______)|/ )_) 9 | # 10 | # Configuration File 11 | 12 | # Top level location of your blog 13 | host: http://localhost:8000/ 14 | # Name of your blog 15 | blog_name: Testing ground 16 | 17 | # Salt for argon2 hashing. Note this is NOT used for passwords, only for keeping things somewhat anonymous. 18 | salt: 3BooSGokWgZXfae7WxhGZ 19 | 20 | # Author details. Comment options out if you don't want them included in your signature. 21 | # At least one option must remain uncommented, but can be left blank if you want this feature disabled. 22 | author: 23 | name: 24 | email: 25 | url: 26 | 27 | # Maximum thread nesting value. Depending on your layout, comments will get really bunched up and difficult 28 | # to read. So replies after a certain depth will no longer nest, but instead just respond to the current parent. 29 | nesting_limit: 6 30 | 31 | # Time (in seconds) in which a user can edit or delete their own comment. 32 | edit_timeout: 120 33 | 34 | # Email notifications can be sent to you when certain events occur. Toggle each boolean value you wish to be 35 | # notified of here, and set up your smtp server details below. These values are sent encrypted by default. 36 | # For now, your pasword will be plain text in this file, so make sure your permissions are tight. In future 37 | # versions of Oration an init script will save your password in a strongly encrypted format in the database. 38 | notifications: 39 | new_comment: false 40 | smtp_server: 41 | host: 42 | user_name: 43 | password: 44 | recipient: 45 | email: 46 | name: 47 | 48 | # If you're a telegram user, it's possible to set up push notifications to your own personal bot. 49 | # This may change to a global bot in the future if users do not wish to generate their own integrations. 50 | # Documentation forthcoming. 51 | telegram: 52 | push_notifications: false 53 | bot_id: 54 | chat_id: 55 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use serde_yaml; 2 | 3 | use errors::*; 4 | use models::comments::gen_hash; 5 | use std::fs::File; 6 | 7 | /// The main struct which all input data from `oration.yaml` is pushed into. 8 | #[derive(Serialize, Deserialize, Debug)] 9 | pub struct Config { 10 | /// Top level location of the blog we are serving. 11 | pub host: String, 12 | /// Name of the blog we are serving. 13 | pub blog_name: String, 14 | /// A salt for slightly more anonymous `anonymous` user identification. 15 | pub salt: String, 16 | /// Blog Author to highlight as an authority in comments. 17 | pub author: Author, 18 | /// Limit of thread nesting in comments. 19 | pub nesting_limit: u32, 20 | /// Time limit that restricts user editing of their own comments. 21 | pub edit_timeout: f32, 22 | /// Email notification system and connection details. 23 | pub notifications: Notifications, 24 | /// Telegram notification endpoint details. 25 | pub telegram: Telegram, 26 | } 27 | 28 | impl Config { 29 | /// Reads and parses data from the `oration.yaml` file and command line arguments. 30 | pub fn load() -> Result { 31 | let reader = File::open("oration.yaml").chain_err(|| ErrorKind::ConfigLoad)?; 32 | // Decode configuration file. 33 | let mut decoded_config: Config = 34 | serde_yaml::from_reader(reader).chain_err(|| ErrorKind::Deserialize)?; 35 | Config::parse(&decoded_config).chain_err(|| ErrorKind::ConfigParse)?; 36 | 37 | decoded_config.author.gen_hash(); 38 | 39 | Ok(decoded_config) 40 | } 41 | 42 | /// Additional checks to the configuration file that cannot be done implicitly 43 | /// by the type checker. 44 | fn parse(&self) -> Result<()> { 45 | let handle = self.host.get(0..4); 46 | if handle != Some("http") { 47 | return Err(ErrorKind::NoHTTPHandle.into()); 48 | } 49 | 50 | if self.notifications.new_comment { 51 | // Empty values are parsed as ~, so we want to check for those 52 | if self 53 | .notifications 54 | .smtp_server 55 | .into_iter() 56 | .any(|x| x.is_empty() || x == "~") 57 | { 58 | return Err(ErrorKind::EmptySMTP.into()); 59 | } 60 | if self.notifications.recipient.email.is_empty() 61 | || self.notifications.recipient.email == "~" 62 | { 63 | return Err(ErrorKind::EmptyRecipientEmail.into()); 64 | } 65 | } 66 | Ok(()) 67 | } 68 | } 69 | 70 | /// Details of the blog author. 71 | #[derive(Serialize, Deserialize, Debug)] 72 | pub struct Author { 73 | /// Blog author's name. 74 | name: Option, 75 | /// Blog author's email address. 76 | email: Option, 77 | /// Blog author's website. 78 | url: Option, 79 | #[serde(skip)] 80 | /// A Sha224 hash of the blog author's details (automitically generated). 81 | pub hash: String, 82 | } 83 | 84 | impl Author { 85 | /// Generates a Sha224 hash for the blog author if details are set. 86 | fn gen_hash(&mut self) { 87 | self.hash = gen_hash(&self.name, &self.email, &self.url, None); 88 | } 89 | } 90 | 91 | /// Details of the email notification system. 92 | #[derive(Serialize, Deserialize, Debug)] 93 | pub struct Notifications { 94 | /// Toggle if an email is to be sent when a new comment is posted. 95 | pub new_comment: bool, 96 | /// SMTP connection details. 97 | pub smtp_server: SMTPServer, 98 | /// Who to send the notification to. 99 | pub recipient: Recipient, 100 | } 101 | 102 | /// Details of the SMTP server which the notification system should connect to. 103 | #[derive(Serialize, Deserialize, Debug)] 104 | pub struct SMTPServer { 105 | /// SMTP host url. (No need for a protocol header). 106 | pub host: String, 107 | /// Username for authentication. 108 | pub user_name: String, 109 | /// Password for authentication. 110 | pub password: String, 111 | } 112 | 113 | impl<'a> IntoIterator for &'a SMTPServer { 114 | type Item = &'a str; 115 | type IntoIter = SMTPServerIterator<'a>; 116 | 117 | fn into_iter(self) -> Self::IntoIter { 118 | SMTPServerIterator { 119 | server: self, 120 | index: 0, 121 | } 122 | } 123 | } 124 | 125 | /// Iterator helper for `SMTPServer` 126 | pub struct SMTPServerIterator<'a> { 127 | /// The SMTPServer struct. 128 | server: &'a SMTPServer, 129 | /// A helper index. 130 | index: usize, 131 | } 132 | 133 | impl<'a> Iterator for SMTPServerIterator<'a> { 134 | type Item = &'a str; 135 | fn next(&mut self) -> Option<&'a str> { 136 | let result = match self.index { 137 | 0 => &self.server.host, 138 | 1 => &self.server.user_name, 139 | 2 => &self.server.password, 140 | _ => return None, 141 | }; 142 | self.index += 1; 143 | Some(result) 144 | } 145 | } 146 | 147 | /// Details of a person to email the notifications to. 148 | #[derive(Serialize, Deserialize, Debug)] 149 | pub struct Recipient { 150 | /// Recipient's email address. 151 | pub email: String, 152 | /// Recipient's name. 153 | pub name: String, 154 | } 155 | 156 | /// Details of the telegram notification system. 157 | #[derive(Serialize, Deserialize, Debug)] 158 | pub struct Telegram { 159 | /// If true, the notification system will be active. 160 | pub push_notifications: bool, 161 | /// API token for your telegram bot. 162 | pub bot_id: String, 163 | /// The ID of your personal chat with the bot. 164 | pub chat_id: String, 165 | } 166 | -------------------------------------------------------------------------------- /src/data.rs: -------------------------------------------------------------------------------- 1 | use rocket::http::Status; 2 | use rocket::request::{self, FromRequest, Request}; 3 | use rocket::Outcome; 4 | 5 | //NOTE: we can use FormInput<'c>, url: &'c RawStr, for unvalidated data if/when we need it. 6 | #[derive(Debug, FromForm)] 7 | /// Incoming data from the web based form for a new comment. 8 | pub struct FormInput { 9 | /// Comment from textarea. 10 | pub comment: String, 11 | /// Parent comment if any. 12 | pub parent: Option, 13 | /// Optional name. 14 | pub name: Option, 15 | /// Optional email. 16 | pub email: Option, 17 | /// Optional website. 18 | pub url: Option, 19 | /// Title of post. 20 | pub title: String, 21 | /// Path of post. 22 | pub path: String, 23 | } 24 | 25 | impl FormInput { 26 | /// Yields the senders name with a default if is empty. 27 | pub fn sender_name(&self) -> String { 28 | self.name 29 | .to_owned() 30 | .unwrap_or_else(|| "anonymous".to_string()) 31 | } 32 | 33 | /// Yields the senders email address with a default if is empty. 34 | pub fn sender_email(&self) -> String { 35 | self.email 36 | .to_owned() 37 | .unwrap_or_else(|| "noreply@dev.null".to_string()) 38 | } 39 | } 40 | 41 | #[derive(Debug, FromForm)] 42 | /// Incoming data from the web based form for an edited comment. 43 | pub struct FormEdit { 44 | /// Comment from textarea. 45 | pub comment: String, 46 | /// Optional name. 47 | pub name: Option, 48 | /// Optional email. 49 | pub email: Option, 50 | /// Optional website. 51 | pub url: Option, 52 | } 53 | 54 | /// Hash of the user which wants to edit/delete a comment. 55 | #[derive(PartialEq)] 56 | pub struct AuthHash(String); 57 | 58 | impl<'a, 'r> FromRequest<'a, 'r> for AuthHash { 59 | type Error = (); 60 | 61 | fn from_request(request: &'a Request<'r>) -> request::Outcome { 62 | let keys: Vec<_> = request.headers().get("x-auth-hash").collect(); 63 | if keys.len() != 1 { 64 | return Outcome::Failure((Status::BadRequest, ())); 65 | } 66 | 67 | let key = keys[0]; 68 | Outcome::Success(AuthHash(key.to_string())) 69 | } 70 | } 71 | 72 | impl AuthHash { 73 | /// Checkhs if the current hash matches with the `compared` hash. 74 | pub fn matches(&self, compare: &str) -> bool { 75 | let &AuthHash(ref hash) = self; 76 | hash == compare 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/db.rs: -------------------------------------------------------------------------------- 1 | use diesel::sqlite::SqliteConnection; 2 | use dotenv::dotenv; 3 | use r2d2; 4 | use r2d2_diesel::ConnectionManager; 5 | use rocket::http::Status; 6 | use rocket::request::{self, FromRequest}; 7 | use rocket::{Outcome, Request, State}; 8 | use std::env; 9 | use std::ops::Deref; 10 | 11 | /// An alias to the type for a pool of Diesel `SQLite` connections. 12 | pub type Pool = r2d2::Pool>; 13 | 14 | /// Initializes a database pool. 15 | pub fn init_pool() -> Pool { 16 | dotenv().ok(); 17 | let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); 18 | let manager = ConnectionManager::::new(database_url); 19 | r2d2::Pool::new(manager).expect("db pool") 20 | } 21 | 22 | /// Connection request guard type: a wrapper around an r2d2 pooled connection. 23 | pub struct Conn(pub r2d2::PooledConnection>); 24 | 25 | // For the convenience of using an &Conn as an &SqliteConnection. 26 | impl Deref for Conn { 27 | type Target = SqliteConnection; 28 | 29 | #[inline(always)] 30 | fn deref(&self) -> &Self::Target { 31 | &self.0 32 | } 33 | } 34 | 35 | /// Attempts to retrieve a single connection from the managed database pool. If 36 | /// no pool is currently managed, fails with an `InternalServerError` status. If 37 | /// no connections are available, fails with a `ServiceUnavailable` status. 38 | impl<'a, 'r> FromRequest<'a, 'r> for Conn { 39 | type Error = (); 40 | 41 | fn from_request(request: &'a Request<'r>) -> request::Outcome { 42 | let pool = match request.guard::>() { 43 | Outcome::Success(pool) => pool, 44 | Outcome::Failure(e) => return Outcome::Failure(e), 45 | Outcome::Forward(_) => return Outcome::Forward(()), 46 | }; 47 | 48 | match pool.get() { 49 | Ok(conn) => Outcome::Success(Conn(conn)), 50 | Err(_) => Outcome::Failure((Status::ServiceUnavailable, ())), 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | error_chain!{ 2 | errors { 3 | SessionHash { 4 | description("Cannot generate session hash") 5 | display("Unable to generate a session hash") 6 | } 7 | NoSession { 8 | description("Cannot read session info") 9 | display("Unable to read session information from database") 10 | } 11 | NoThread(uri: String) { 12 | description("Cannot read thread info") 13 | display("Unable to read thread information for {} from database", uri) 14 | } 15 | DBRead { 16 | description("Cannot parse db response") 17 | display("Unable to parse response from database query") 18 | } 19 | DBInsert { 20 | description("Cannot insert data in db") 21 | display("Database query to insert data failed") 22 | } 23 | Unauthorized { 24 | description("Cannot identify user") 25 | display("Unable to complete request without correct authorization") 26 | } 27 | Rand { 28 | description("Cannot generate random number") 29 | display("Unable to call /dev/urandom") 30 | } 31 | ConfigLoad { 32 | description("Config file not found") 33 | display("Unable to read configuration file oration.yaml") 34 | } 35 | ConfigParse { 36 | description("Error parsing config") 37 | display("an error occurred trying to parse the configuratation file") 38 | } 39 | Deserialize { 40 | description("Cannot deserialize data") 41 | display("Unable to deserialize data to required struct") 42 | } 43 | Serialize { 44 | description("Cannot serialize data") 45 | display("Unable to serialize data to required object") 46 | } 47 | NoHTTPHandle { 48 | description("No HTTP handle") 49 | display("The configuration parameter 'host' requires either a http:// or https:// prefix") 50 | } 51 | EmptySMTP { 52 | description("Invalid SMTP configuration") 53 | display("Email notifications have been enabled, but one or more of the SMTP server configuration options are empty") 54 | } 55 | EmptyRecipientEmail { 56 | description("Invalid Recipient configuration") 57 | display("Email notifications have been enabled, but no email address has been given to send notifications to") 58 | } 59 | TelegramNotify { 60 | description("Telegram Notification failed") 61 | display("Response from Telegram API was not OK") 62 | } 63 | Request { 64 | description("HTTP request failed") 65 | display("Could not generate HTTP request") 66 | } 67 | PathCheckFailed { 68 | description("Requested path does not exist") 69 | display("Could not find path on blog server") 70 | } 71 | BuildEmail { 72 | description("Failed to build email") 73 | display("Could not construct notification email") 74 | } 75 | SendEmail { 76 | description("Failed to send email") 77 | display("Could not send notification email") 78 | } 79 | BuildSmtpTransport { 80 | description("Failed SMTP handshake") 81 | display("Could not attach to SMTP server") 82 | } 83 | AlreadyVoted { 84 | description("Cannot Re-Vote") 85 | display("User has already voted on this comment") 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! Oration: a Rocket/Elm self hosted commenting system for static sites. 2 | //! 3 | //! Inspired by ![Isso](https://posativ.org/isso/), which is a welcomed change from Disqus. 4 | //! However, the codebase is unmaintained and ![security concerns](https://axiomatic.neophilus.net/posts/2017-04-16-from-disqus-to-isso.html) abound. 5 | //! Oration aims to be a fast, lightweight and secure platform for your comments. Nothing more, but importantly, nothing less. 6 | 7 | #![cfg_attr( 8 | feature = "cargo-clippy", 9 | warn(missing_docs_in_private_items) 10 | )] 11 | #![cfg_attr(feature = "cargo-clippy", warn(single_match_else))] 12 | #![feature(plugin, custom_derive, use_extern_macros)] 13 | #![plugin(rocket_codegen)] 14 | // `error_chain!` can recurse deeply 15 | #![recursion_limit = "1024"] 16 | 17 | extern crate bincode; 18 | extern crate chrono; 19 | #[macro_use] 20 | extern crate diesel; 21 | extern crate dotenv; 22 | #[macro_use] 23 | extern crate error_chain; 24 | extern crate petgraph; 25 | extern crate r2d2; 26 | extern crate r2d2_diesel; 27 | extern crate rand; 28 | extern crate rocket; 29 | extern crate rocket_contrib; 30 | #[macro_use] 31 | extern crate serde_derive; 32 | extern crate yansi; 33 | //extern crate argon2rs; 34 | extern crate bloomfilter; 35 | extern crate crypto; 36 | extern crate itertools; 37 | #[macro_use] 38 | extern crate lazy_static; 39 | extern crate lettre; 40 | extern crate lettre_email; 41 | #[macro_use(log)] 42 | extern crate log; 43 | extern crate openssl_probe; 44 | extern crate regex; 45 | extern crate reqwest; 46 | extern crate serde_yaml; 47 | 48 | /// Loads configuration data from disk. 49 | mod config; 50 | /// Houses Data Structures that are needed in multiple modules. 51 | mod data; 52 | /// Handles the database connection pool. 53 | mod db; 54 | /// Handles the error chain of the program. 55 | mod errors; 56 | /// SQL <----> Rust inerop using Diesel. 57 | mod models; 58 | /// Sends notifications to admin. 59 | mod notify; 60 | /// Verbose schema for the comment database. 61 | mod schema; 62 | /// Serves up static files through Rocket. 63 | mod static_files; 64 | /// Tests for the Rocket side of the app. 65 | #[cfg(test)] 66 | mod tests; 67 | 68 | use config::Config; 69 | use crypto::digest::Digest; 70 | use crypto::sha2::Sha224; 71 | use data::{AuthHash, FormEdit, FormInput}; 72 | use errors::Error; 73 | use models::comments::{self, Comment, CommentEdits, InsertedComment, NestedComment}; 74 | use models::preferences::Preference; 75 | use models::threads; 76 | use rocket::http::Status; 77 | use rocket::request::Form; 78 | use rocket::response::{status, Failure, NamedFile}; 79 | use rocket::State; 80 | use rocket_contrib::Json; 81 | use std::io; 82 | use std::net::SocketAddr; 83 | use std::process; 84 | use yansi::Paint; 85 | 86 | /// Serve up the index file. This is only useful for development. Should not be used in a release. 87 | //TODO: Serve this some other way, we don't want oration doing this work. 88 | #[get("/")] 89 | fn index() -> io::Result { 90 | NamedFile::open("public/index.html") 91 | } 92 | 93 | /// Process comment input from form. 94 | #[post("/oration", data = "")] 95 | fn new_comment( 96 | conn: db::Conn, 97 | comment: Result, Option>, 98 | config: State, 99 | remote_addr: SocketAddr, 100 | ) -> Result, Failure> { 101 | match comment { 102 | Ok(f) => { 103 | //If the comment form data is valid, proceed to comment insertion 104 | let form = f.into_inner(); 105 | let ip_addr = remote_addr.ip().to_string(); 106 | //Get thread id from the db, create if needed 107 | match threads::gen_or_get_id(&conn, &config.host, &form.title, &form.path) { 108 | Ok(tid) => { 109 | match Comment::insert(&conn, tid, &form, &ip_addr, config.nesting_limit) { 110 | Err(err) => { 111 | //Something went wrong, return a 500 112 | print_errors(&err); 113 | Err(Failure(Status::InternalServerError)) 114 | } 115 | Ok(comment) => { 116 | //All good, return the comment 117 | //Send notification to admin 118 | if config.notifications.new_comment { 119 | match notify::send_notification( 120 | &form, 121 | &config.notifications, 122 | &config.host, 123 | &config.blog_name, 124 | &ip_addr, 125 | ) { 126 | Ok(_) => log::info!( 127 | "📧 {}", 128 | Paint::blue("New comment email notification sent.") 129 | ), 130 | Err(err) => { 131 | print_errors(&err); 132 | } 133 | } 134 | } 135 | if config.telegram.push_notifications { 136 | match notify::push_telegram( 137 | &form, 138 | &config.telegram, 139 | &config.host, 140 | &ip_addr, 141 | ) { 142 | Ok(_) => log::info!( 143 | "📧 {}", 144 | Paint::blue( 145 | "New comment push notification sent to Telegram." 146 | ) 147 | ), 148 | Err(err) => { 149 | print_errors(&err); 150 | } 151 | } 152 | } 153 | Ok(Json(comment)) 154 | } 155 | } 156 | } 157 | Err(err) => { 158 | //We didn't get the thread id 159 | print_errors(&err); 160 | match err { 161 | errors::Error(errors::ErrorKind::PathCheckFailed, _) => { 162 | //The requsted path doesn't exist on the server 163 | //Most likely an attempt at injecting junk into the db through the post method 164 | Err(Failure(Status::Forbidden)) 165 | } 166 | _ => Err(Failure(Status::InternalServerError)), 167 | } 168 | } 169 | } 170 | } 171 | Err(_) => { 172 | //The form request was malformed, 400 173 | Err(Failure(Status::BadRequest)) 174 | } 175 | } 176 | } 177 | 178 | /// Information sent to the client upon initialisation. 179 | #[derive(Serialize)] 180 | struct Initialise { 181 | /// The clients' ip address, hashed via Sha224. 182 | user_ip: String, 183 | /// The Sha224 hash of the blog author to distinguish the authority on this blog. 184 | blog_author: String, 185 | /// Time frame in which users can edit thier own comments. 186 | edit_timeout: f32, 187 | } 188 | 189 | /// Gets a Sha224 hash from a clients IP along with the blog's author hash. 190 | #[get("/oration/init")] 191 | fn initialise(remote_addr: SocketAddr, config: State) -> Json { 192 | let ip_addr = remote_addr.ip().to_string(); 193 | // create a Sha224 object 194 | let mut hasher = Sha224::new(); 195 | // write input message 196 | hasher.input_str(&ip_addr); 197 | 198 | let to_send = Initialise { 199 | user_ip: hasher.result_str(), 200 | blog_author: config.author.hash.to_owned(), 201 | edit_timeout: config.edit_timeout, 202 | }; 203 | 204 | Json(to_send) 205 | } 206 | 207 | #[derive(FromForm, Copy, Clone)] 208 | /// Used in conjuction with `/delete?` and `/edit?`. 209 | struct CommentId { 210 | /// The id of the requested comment. 211 | id: i32, 212 | } 213 | 214 | /// Requests comment deletion from a user, may or may not actually delete the comment 215 | /// based on a number of possibilities: authentication issues, over time, etc. 216 | /// Secondarily, the method of deletion may differ. If the comment has children it 217 | /// is not deleted entirely, but flagged so that the rest of the conversation is not 218 | /// automatically pruned. 219 | #[delete("/oration/delete?")] 220 | fn delete_comment( 221 | conn: db::Conn, 222 | config: State, 223 | identifier: CommentId, 224 | hash: AuthHash, 225 | ) -> Result { 226 | if let Err(err) = comments::update_authorised(&conn, &hash, identifier.id, config.edit_timeout) 227 | { 228 | print_errors(&err); 229 | return Err(Failure(Status::Unauthorized)); 230 | }; 231 | match Comment::delete(&conn, identifier.id) { 232 | Ok(_) => Ok(identifier.id.to_string()), 233 | Err(err) => { 234 | print_errors(&err); 235 | Err(Failure(Status::NotFound)) 236 | } 237 | } 238 | } 239 | 240 | /// Requests an update to a comment from a user, which may or may not occur 241 | /// based on a number of possibilities: authentication issues, over time, etc. 242 | #[post("/oration/edit?", data = "")] 243 | fn edit_comment( 244 | conn: db::Conn, 245 | config: State, 246 | identifier: CommentId, 247 | hash: AuthHash, 248 | edits: Result, Option>, 249 | remote_addr: SocketAddr, 250 | ) -> Result, Failure> { 251 | if let Err(err) = comments::update_authorised(&conn, &hash, identifier.id, config.edit_timeout) 252 | { 253 | print_errors(&err); 254 | return Err(Failure(Status::Unauthorized)); 255 | }; 256 | match edits { 257 | Ok(f) => { 258 | //If the comment form data is valid, proceed to updating the comment 259 | let form = f.into_inner(); 260 | let ip_addr = remote_addr.ip().to_string(); 261 | match Comment::update(&conn, identifier.id, &form, &ip_addr) { 262 | Ok(edits) => Ok(Json(edits)), 263 | Err(err) => { 264 | print_errors(&err); 265 | Err(Failure(Status::NotFound)) 266 | } 267 | } 268 | } 269 | Err(_) => { 270 | //The form request was malformed or not UTF8 encoded: 400 271 | Err(Failure(Status::BadRequest)) 272 | } 273 | } 274 | } 275 | 276 | /// Likes a comment so long as the current user has not done so already. 277 | #[post("/oration/like?")] 278 | fn like_comment( 279 | conn: db::Conn, 280 | identifier: CommentId, 281 | remote_addr: SocketAddr, 282 | ) -> Result> { 283 | let ip_addr = remote_addr.ip().to_string(); 284 | match Comment::vote(&conn, identifier.id, &ip_addr, true) { 285 | Ok(_) => Ok(identifier.id.to_string()), 286 | Err(err) => { 287 | print_errors(&err); 288 | //TODO: A custom status with a Failure that doesn't require &'static strings 289 | Err(status::Custom(Status::Forbidden, identifier.id.to_string())) 290 | } 291 | } 292 | } 293 | 294 | /// Dislikes a comment so long as the current user has not done so already. 295 | #[post("/oration/dislike?")] 296 | fn dislike_comment( 297 | conn: db::Conn, 298 | identifier: CommentId, 299 | remote_addr: SocketAddr, 300 | ) -> Result> { 301 | let ip_addr = remote_addr.ip().to_string(); 302 | match Comment::vote(&conn, identifier.id, &ip_addr, false) { 303 | Ok(_) => Ok(identifier.id.to_string()), 304 | Err(err) => { 305 | print_errors(&err); 306 | Err(status::Custom(Status::Forbidden, identifier.id.to_string())) 307 | } 308 | } 309 | } 310 | 311 | /// Test function that returns the session hash from the database. 312 | #[get("/oration/session")] 313 | fn get_session(conn: db::Conn) -> String { 314 | match Preference::get_session(&conn) { 315 | Ok(s) => s, 316 | Err(err) => { 317 | print_errors(&err); 318 | err.to_string() 319 | } 320 | } 321 | } 322 | 323 | #[derive(FromForm)] 324 | /// Used in conjuction with `/count?` and `/comments?`. 325 | struct Post { 326 | /// Gets the url for the request. 327 | url: String, 328 | } 329 | 330 | #[derive(Serialize)] 331 | /// Comments to frontend 332 | struct PostComments { 333 | /// A nested set of comments. 334 | comments: Vec, 335 | } 336 | 337 | /// Return a json block of comment data for the requested url. 338 | #[get("/oration/comments?")] 339 | fn get_comments(conn: db::Conn, post: Post) -> Option> { 340 | //TODO: The logic here may not 100%, need to consider / vs /index.* for example. 341 | match NestedComment::list(&conn, &post.url) { 342 | Ok(comments) => { 343 | //We now have a vector of comments 344 | let to_send = PostComments { comments }; 345 | Some(Json(to_send)) 346 | } 347 | Err(err) => { 348 | print_errors(&err); 349 | None 350 | } 351 | } 352 | } 353 | 354 | /// Returns the comment count for a given post from the database. 355 | #[get("/oration/count?")] 356 | fn get_comment_count(conn: db::Conn, post: Post) -> String { 357 | //TODO: The logic here may not 100%, need to consider / vs /index.* for example. 358 | match Comment::count(&conn, &post.url) { 359 | Ok(s) => s.to_string(), 360 | Err(err) => { 361 | print_errors(&err); 362 | err.to_string() 363 | } 364 | } 365 | } 366 | 367 | /// Prints the current error chain to the log file / stdio. 368 | fn print_errors(err: &Error) { 369 | log::warn!("{}", err); 370 | for e in err.iter().skip(1) { 371 | log::warn!(" {} {}", Paint::white("=> Caused by:"), Paint::red(&e)); 372 | } 373 | } 374 | 375 | /// Ignite Rocket, connect to the database and start serving data. 376 | /// Exposes a connection to the database so we can set the session on startup. 377 | fn rocket() -> (rocket::Rocket, db::Conn, String) { 378 | //Load configuration data from disk 379 | let config = match Config::load() { 380 | Ok(c) => c, 381 | Err(ref err) => { 382 | println!("Error loading configuration: {}", err); 383 | for e in err.iter().skip(1) { 384 | println!("caused by: {}", e); 385 | } 386 | process::exit(1) 387 | } 388 | }; 389 | let host = config.host.clone(); 390 | let pool = db::init_pool(); 391 | let conn = match pool.get() { 392 | Ok(p) => db::Conn(p), 393 | Err(err) => { 394 | println!("Could not connect to database: {}", err); 395 | process::exit(1) 396 | } 397 | }; 398 | let rocket = rocket::ignite().manage(pool).manage(config).mount( 399 | "/", 400 | routes![ 401 | index, //TODO: index and static_files should not be managed by oration 402 | static_files::files, 403 | new_comment, 404 | delete_comment, 405 | edit_comment, 406 | like_comment, 407 | dislike_comment, 408 | initialise, 409 | get_session, 410 | get_comment_count, 411 | get_comments, 412 | ], 413 | ); 414 | 415 | (rocket, conn, host) 416 | } 417 | 418 | /// Application entry point. 419 | fn main() { 420 | openssl_probe::init_ssl_cert_env_vars(); 421 | 422 | //Initialise webserver routes and database connection pool 423 | let (rocket, conn, host) = rocket(); 424 | 425 | //Set the session info in the database 426 | log::info!("💿 {}", Paint::purple("Saving session hash to database")); 427 | match Preference::set_session(&conn) { 428 | Ok(set) => { 429 | if !set { 430 | //TODO: This may need to be a crit as well. Unsure. 431 | log::warn!("Failed to set session hash"); 432 | } 433 | } 434 | Err(err) => { 435 | print_errors(&err); 436 | process::exit(1); 437 | } 438 | }; 439 | 440 | log::info!( 441 | "📢 {} {}", 442 | Paint::blue("Oration will serve comments to"), 443 | host 444 | ); 445 | 446 | //Start the web service 447 | rocket.launch(); 448 | } 449 | -------------------------------------------------------------------------------- /src/models/comments/mod.rs: -------------------------------------------------------------------------------- 1 | use bincode::{deserialize, serialize}; 2 | use bloomfilter::Bloom; 3 | use chrono::{DateTime, NaiveDateTime, Utc}; 4 | use crypto::digest::Digest; 5 | use crypto::sha2::Sha224; 6 | use diesel; 7 | use diesel::expression::dsl::sql; 8 | use diesel::prelude::*; 9 | use diesel::sql_types::Integer; 10 | use diesel::sqlite::SqliteConnection; 11 | use itertools::join; 12 | use petgraph::graphmap::DiGraphMap; 13 | use std::str; 14 | 15 | use data::{AuthHash, FormEdit, FormInput}; 16 | use errors::*; 17 | use schema::comments; 18 | 19 | #[derive(Queryable, Debug)] 20 | /// Queryable reference to the comments table. 21 | pub struct Comment { 22 | /// Primary key. 23 | id: i32, 24 | /// Reference to Thread. 25 | tid: i32, //TODO: Diesel parsed this as a bool. Write up a new issue. 26 | /// Parent comment. 27 | parent: Option, 28 | /// Timestamp of creation. 29 | created: NaiveDateTime, 30 | /// Date modified it that's happened. 31 | modified: Option, 32 | /// If the comment is live or under review. 33 | mode: i32, 34 | /// Remote IP. 35 | remote_addr: Option, 36 | /// Actual comment. 37 | text: String, 38 | /// Commentors author if given. 39 | author: Option, 40 | /// Commentors email address if given. 41 | email: Option, 42 | /// Commentors website if given. 43 | website: Option, 44 | /// Commentors idenifier hash. 45 | hash: String, 46 | /// Number of likes a comment has recieved. 47 | likes: Option, //TODO: I know the tables like i32s, but these really should be unsigned 48 | /// Number of dislikes a comment has recieved. 49 | dislikes: Option, 50 | /// Who are the voters on this comment. 51 | voters: Option>, 52 | } 53 | 54 | #[derive(Insertable, Debug)] 55 | #[table_name = "comments"] 56 | /// Insertable reference to the comments table. 57 | struct NewComment<'c> { 58 | /// Reference to Thread. 59 | tid: i32, 60 | /// Parent comment. 61 | parent: Option, 62 | /// Timestamp of creation. 63 | created: NaiveDateTime, 64 | /// Date modified it that's happened. 65 | modified: Option, 66 | /// If the comment is live or under review. By default an active comment has mode 0. 67 | /// If the admin has reviews turned on, all new comments will be flagged as mode 1, or 68 | /// will be set with a default mode 0 if this feature is not enabled. A comment with mode 69 | /// 2 indicates this comment is `deleted`, although it contains responses below it. The 70 | /// deleted comment with therefore be handled differently. 71 | mode: i32, 72 | /// Remote IP. 73 | remote_addr: Option<&'c str>, 74 | /// Actual comment. 75 | text: &'c str, 76 | /// Commentors author if given. 77 | author: Option, 78 | /// Commentors email address if given. 79 | email: Option, 80 | /// Commentors website if given. 81 | website: Option, 82 | /// Sha224 hash to identify commentor. 83 | hash: String, 84 | /// Number of likes a comment has recieved. 85 | likes: Option, 86 | /// Number of dislikes a comment has recieved. 87 | dislikes: Option, 88 | /// Who are the voters on this comment. 89 | voters: Option>, 90 | } 91 | 92 | impl Comment { 93 | /// Returns the number of comments for a given post denoted via the `path` variable. 94 | pub fn count(conn: &SqliteConnection, path: &str) -> Result { 95 | use schema::threads; 96 | 97 | let comment_count = comments::table 98 | .inner_join(threads::table) 99 | .filter(threads::uri.eq(path)) 100 | .count() 101 | .first(conn) 102 | .chain_err(|| ErrorKind::DBRead)?; 103 | 104 | Ok(comment_count) 105 | } 106 | 107 | /// Stores a new comment into the database. 108 | pub fn insert<'c>( 109 | conn: &SqliteConnection, 110 | tid: i32, 111 | form: &FormInput, 112 | ip_addr: &'c str, 113 | nesting_limit: u32, 114 | ) -> Result { 115 | let time = Utc::now().naive_utc(); 116 | 117 | let ip = if ip_addr.is_empty() { 118 | None //TODO: I wonder if this is ever true? 119 | } else { 120 | Some(ip_addr) 121 | }; 122 | 123 | let parent_id = nesting_check(conn, form.parent, nesting_limit)?; 124 | let hash = gen_hash(&form.name, &form.email, &form.url, Some(ip_addr)); 125 | 126 | let c = NewComment { 127 | tid, 128 | parent: parent_id, 129 | created: time, 130 | modified: None, 131 | mode: 0, 132 | remote_addr: ip, 133 | text: &form.comment, 134 | author: form.name.clone(), 135 | email: form.email.clone(), 136 | website: form.url.clone(), 137 | hash, 138 | likes: None, 139 | dislikes: None, 140 | voters: None, 141 | }; 142 | 143 | let result = diesel::insert_into(comments::table) 144 | .values(&c) 145 | .execute(conn) 146 | .is_ok(); 147 | if result { 148 | //Return a NestedComment formated result of this entry to the front end 149 | let comment_id = comments::table 150 | .select(comments::id) 151 | .order(comments::id.desc()) 152 | .first::(conn) 153 | .chain_err(|| ErrorKind::DBRead)?; 154 | let comment = PrintedComment::get(conn, comment_id)?; 155 | Ok(InsertedComment::new(&comment)) 156 | } else { 157 | Err(ErrorKind::DBInsert.into()) 158 | } 159 | } 160 | 161 | /// Deletes a comment if there is no children, marks as deleted if there are children. 162 | pub fn delete(conn: &SqliteConnection, id: i32) -> Result<()> { 163 | let children_count = comments::table 164 | .filter(comments::parent.eq(id)) 165 | .count() 166 | .first::(conn) 167 | .chain_err(|| ErrorKind::DBRead)?; 168 | if children_count == 0 { 169 | //We can safely delete this comment entirely 170 | diesel::delete(comments::table.filter(comments::id.eq(id))) 171 | .execute(conn) 172 | .chain_err(|| ErrorKind::DBRead)?; 173 | } else { 174 | //This comment must be flagged as deleted instead 175 | let target = comments::table.filter(comments::id.eq(id)); 176 | diesel::update(target) 177 | .set(&ModeDelete { 178 | mode: 2, 179 | remote_addr: None, 180 | text: String::new(), 181 | author: None, 182 | email: None, 183 | website: None, 184 | hash: String::new(), 185 | likes: None, 186 | dislikes: None, 187 | voters: None, 188 | }) 189 | .execute(conn) 190 | .chain_err(|| ErrorKind::DBRead)?; 191 | } 192 | 193 | //Deleted comments may have had children before, but this request may have just 194 | //removed the last one of them. In that case we can completely remove the node 195 | 196 | // We can't chain the IN clause here, so we return it first 197 | // https://github.com/diesel-rs/diesel/issues/1369#issuecomment-351100511 198 | let child = comments::table 199 | .select(comments::parent) 200 | .filter(comments::parent.is_not_null()) 201 | .load::>(conn) 202 | .chain_err(|| ErrorKind::DBRead)?; 203 | // child is now a Vec>, where all of the Options must be Some. Let's unwrap them. 204 | let child_unwrapped: Vec = child.into_iter().map(|c| c.unwrap_or_else(|| 0)).collect(); 205 | let target = comments::table 206 | .filter(comments::mode.eq(2)) 207 | .filter(comments::id.ne_all(child_unwrapped)); 208 | diesel::delete(target) 209 | .execute(conn) 210 | .chain_err(|| ErrorKind::DBRead)?; 211 | 212 | Ok(()) 213 | } 214 | 215 | /// Updates a comment. 216 | pub fn update<'c>( 217 | conn: &SqliteConnection, 218 | id: i32, 219 | data: &FormEdit, 220 | ip_addr: &'c str, 221 | ) -> Result { 222 | let target = comments::table.filter(comments::id.eq(id)); 223 | let hash = gen_hash(&data.name, &data.email, &data.url, Some(ip_addr)); 224 | let time = Utc::now().naive_utc(); 225 | diesel::update(target) 226 | .set(( 227 | comments::text.eq(data.comment.to_owned()), 228 | comments::author.eq(data.name.to_owned()), 229 | comments::email.eq(data.email.to_owned()), 230 | comments::website.eq(data.url.to_owned()), 231 | comments::hash.eq(hash), 232 | comments::modified.eq(Some(time)), 233 | )) 234 | .execute(conn) 235 | .chain_err(|| ErrorKind::DBRead)?; 236 | let comment = PrintedComment::get(conn, id)?; 237 | Ok(CommentEdits::new(&comment)) 238 | } 239 | 240 | /// Called from the like and dislike functions and updates the vote tally for the 241 | /// given comment, provided the user is able to vote on this comment. 242 | /// We use the user's IP address here rather than the hash to ratelimit voting from 243 | /// the same IP by changing user details or spamming hash headers. 244 | pub fn vote<'c>( 245 | conn: &SqliteConnection, 246 | id: i32, 247 | ip_addr: &'c str, 248 | upvote: bool, 249 | ) -> Result<()> { 250 | let voters_blob = comments::table 251 | .select(comments::voters) 252 | .filter(comments::id.eq(id)) 253 | .first::>>(conn) 254 | .chain_err(|| ErrorKind::DBRead)?; 255 | 256 | let mut can_vote = true; 257 | if let Some(voters) = voters_blob { 258 | let blob: VotersBlob = deserialize(&voters).unwrap(); 259 | let mut bloom = 260 | Bloom::from_existing(&blob.bitmap, blob.bits, blob.hashes, blob.sip_keys); 261 | if bloom.check_and_set(ip_addr) { 262 | //The IP is already in the database, so the user has already voted 263 | //for the moment, this means once a vote is cast, we don't allow a user to change 264 | //their vote 265 | can_vote = false; 266 | } else { 267 | //The IP is not in the database, the updated filter needs to be stored 268 | blob.store(conn, id)?; 269 | } 270 | } else { 271 | // New bloomfilter with 95% success rate, give it space for 150 votes by default 272 | let mut bloom = Bloom::new_for_fp_rate(150, 0.05); 273 | // Add the current user's IP to the filter 274 | bloom.set(ip_addr); 275 | 276 | let blob = VotersBlob::new(&bloom); 277 | blob.store(conn, id)?; 278 | } 279 | if can_vote { 280 | let target = comments::table.filter(comments::id.eq(id)); 281 | // It would be nice to extract the `set` line here, but I can't seem to figure out how 282 | if upvote { 283 | diesel::update(target) 284 | .set(comments::likes.eq(comments::likes + 1)) 285 | .execute(conn) 286 | .chain_err(|| ErrorKind::DBRead)?; 287 | } else { 288 | diesel::update(target) 289 | .set(comments::dislikes.eq(comments::dislikes + 1)) 290 | .execute(conn) 291 | .chain_err(|| ErrorKind::DBRead)?; 292 | }; 293 | Ok(()) 294 | } else { 295 | Err(ErrorKind::AlreadyVoted.into()) 296 | } 297 | } 298 | } 299 | 300 | #[derive(Serialize, Deserialize, PartialEq, Debug)] 301 | /// Bloom encoding for voters. Currently more a testing phase than final product. 302 | struct VotersBlob { 303 | /// Probabilistic matrix. 304 | bitmap: Vec, 305 | /// Number of bits in filter. 306 | bits: u64, 307 | /// All hashes in the filter. 308 | hashes: u32, 309 | /// Required sip keys. 310 | sip_keys: [(u64, u64); 2], 311 | } 312 | 313 | impl VotersBlob { 314 | /// Generate a voters struct. 315 | fn new(bloom: &Bloom) -> VotersBlob { 316 | VotersBlob { 317 | bitmap: bloom.bitmap(), 318 | bits: bloom.number_of_bits(), 319 | hashes: bloom.number_of_hash_functions(), 320 | sip_keys: bloom.sip_keys(), 321 | } 322 | } 323 | 324 | /// Encode the bloom filter and store it in the database. 325 | fn store(self, conn: &SqliteConnection, id: i32) -> Result<()> { 326 | let blob_encoded: Vec = serialize(&self).chain_err(|| ErrorKind::Serialize)?; 327 | 328 | let target = comments::table.filter(comments::id.eq(id)); 329 | diesel::update(target) 330 | .set(comments::voters.eq(blob_encoded)) 331 | .execute(conn) 332 | .chain_err(|| ErrorKind::DBRead)?; 333 | 334 | Ok(()) 335 | } 336 | } 337 | 338 | #[derive(AsChangeset)] 339 | #[table_name = "comments"] 340 | #[changeset_options(treat_none_as_null = "true")] 341 | /// Changes required when we must use the flagged delete option. 342 | struct ModeDelete { 343 | /// If the comment is live or under review. 344 | mode: i32, 345 | /// Remote IP. 346 | remote_addr: Option, 347 | /// Actual comment. 348 | text: String, 349 | /// Commentors author if given. 350 | author: Option, 351 | /// Commentors email address if given. 352 | email: Option, 353 | /// Commentors website if given. 354 | website: Option, 355 | /// Commentors idenifier hash. 356 | hash: String, 357 | /// Number of likes a comment has recieved. 358 | likes: Option, 359 | /// Number of dislikes a comment has recieved. 360 | dislikes: Option, 361 | /// Who are the voters on this comment. 362 | voters: Option>, 363 | } 364 | 365 | /// Checks if this comment is nested too deep based on the configuration file value. 366 | /// If so, don't allow this to happen and just post as a reply to the previous parent. 367 | fn nesting_check( 368 | conn: &SqliteConnection, 369 | parent: Option, 370 | nesting_limit: u32, 371 | ) -> Result> { 372 | match parent { 373 | Some(pid) => { 374 | //NOTE: UNION ALL and WITH RECURSIVE are currently not supported by diesel 375 | //https://github.com/diesel-rs/diesel/issues/33 376 | //https://github.com/diesel-rs/diesel/issues/356 377 | //So this is implemented in native SQL for the moment 378 | //TODO: since SqlLiteral#bind is depreciated, we should be using `sql_query` 379 | //here. However: we're building a virtual table and pulling a count from it. 380 | //Diesel for the moment AFAIK is not a happy camper about this. 381 | let mut query = String::from( 382 | "WITH RECURSIVE node_ancestors(node_id, parent_id) AS ( 383 | SELECT id, id FROM comments WHERE id = ", 384 | ); 385 | query.push_str(&pid.to_string()); 386 | query.push_str( 387 | " 388 | UNION ALL 389 | SELECT na.node_id, comments.parent 390 | FROM node_ancestors AS na, comments 391 | WHERE comments.id = na.parent_id AND comments.parent IS NOT NULL 392 | ) 393 | SELECT COUNT(parent_id) AS depth FROM node_ancestors GROUP BY node_id;", 394 | ); 395 | let parent_depth: Vec = sql::(&query) 396 | .load(conn) 397 | .chain_err(|| ErrorKind::DBRead)?; 398 | 399 | if parent_depth.is_empty() || parent_depth[0] <= nesting_limit as i32 { 400 | //We're fine to nest 401 | Ok(Some(pid as i32)) 402 | } else { 403 | //We've hit the limit, reply to the current parent's parent only. 404 | let parents_parent: Option = comments::table 405 | .select(comments::parent) 406 | .filter(comments::id.eq(pid)) 407 | .first(conn) 408 | .chain_err(|| ErrorKind::DBRead)?; 409 | Ok(parents_parent) 410 | } 411 | } 412 | None => Ok(None), //We don't need to worry about this check for new comments 413 | } 414 | } 415 | 416 | /// Generates a Sha224 hash of author details. 417 | /// If none are set, then the possiblity of using a clients' IP address is available. 418 | pub fn gen_hash( 419 | author: &Option, 420 | email: &Option, 421 | url: &Option, 422 | ip_addr: Option<&str>, 423 | ) -> String { 424 | // Generate users sha224 hash 425 | let mut hasher = Sha224::new(); 426 | //TODO: This section is pretty nasty at the moment. 427 | //There has to be a better way to organise this. 428 | let is_data = { 429 | //Check if any of the optional values have data in them 430 | let user = [&author, &email, &url]; 431 | user.into_iter().any(|&v| v.is_some()) 432 | }; 433 | if is_data { 434 | //Generate a set of data to hash 435 | let mut data: Vec = Vec::new(); 436 | if let Some(val) = author.clone() { 437 | data.push(val) 438 | }; 439 | if let Some(val) = email.clone() { 440 | data.push(val) 441 | }; 442 | if let Some(val) = url.clone() { 443 | data.push(val) 444 | }; 445 | //Join with 'b' since it gives the author a nice identicon 446 | hasher.input_str(&join(data.iter(), "b")); 447 | } else if let Some(ip) = ip_addr { 448 | //If we have no data but an ip, hash the ip, otherwise return an empty string 449 | hasher.input_str(ip); 450 | } else { 451 | return String::default(); 452 | } 453 | hasher.result_str() 454 | } 455 | 456 | /// We only want users to be able to edit their comments if they accidentally produced a 457 | /// spelling mistake or somesuch. This method removes that ablility after some `offset` time. 458 | pub fn update_authorised( 459 | conn: &SqliteConnection, 460 | hash: &AuthHash, 461 | id: i32, 462 | offset: f32, 463 | ) -> Result<()> { 464 | let (stored_hash, created, modified) = comments::table 465 | .select((comments::hash, comments::created, comments::modified)) 466 | .filter(comments::id.eq(id)) 467 | .first::<(String, NaiveDateTime, Option)>(conn) 468 | .chain_err(|| ErrorKind::DBRead)?; 469 | 470 | // Check we haven't timed out 471 | let now_timestamp = Utc::now().naive_utc().timestamp(); 472 | 473 | let updated_timestamp = { 474 | if let Some(mod_time) = modified { 475 | mod_time.timestamp() 476 | } else { 477 | created.timestamp() 478 | } 479 | }; 480 | 481 | if hash.matches(&stored_hash) & (now_timestamp - updated_timestamp < (offset as i64)) { 482 | Ok(()) 483 | } else { 484 | Err(ErrorKind::Unauthorized.into()) 485 | } 486 | } 487 | 488 | #[derive(Serialize, Queryable, Debug)] 489 | /// Subset of the comments table which is to be sent to the frontend. 490 | struct PrintedComment { 491 | /// Primary key. 492 | id: i32, 493 | /// Parent comment. 494 | parent: Option, 495 | /// Actual comment. 496 | text: String, 497 | /// Commentors author if given. 498 | author: Option, 499 | /// Commentors email address if given. 500 | email: Option, 501 | /// Commentors website if given. 502 | url: Option, 503 | /// Commentors indentifier. 504 | hash: String, 505 | /// Timestamp of creation. 506 | created: NaiveDateTime, 507 | /// Number of likes a comment has recieved. 508 | likes: Option, 509 | /// Number of dislikes a comment has recieved. 510 | dislikes: Option, 511 | } 512 | 513 | impl PrintedComment { 514 | /// Returns a list of all comments for a given post denoted via the `path` variable. 515 | fn list(conn: &SqliteConnection, path: &str) -> Result> { 516 | use schema::threads; 517 | 518 | let comments: Vec = comments::table 519 | .select(( 520 | comments::id, 521 | comments::parent, 522 | comments::text, 523 | comments::author, 524 | comments::email, 525 | comments::website, 526 | comments::hash, 527 | comments::created, 528 | comments::likes, 529 | comments::dislikes, 530 | )) 531 | .inner_join(threads::table) 532 | .filter( 533 | threads::uri 534 | .eq(path) 535 | .and(comments::mode.eq(0).or(comments::mode.eq(2))), 536 | ) 537 | .load(conn) 538 | .chain_err(|| ErrorKind::DBRead)?; 539 | Ok(comments) 540 | } 541 | 542 | /// Returns a comment based on its' unique ID. 543 | pub fn get(conn: &SqliteConnection, id: i32) -> Result { 544 | let comment: PrintedComment = comments::table 545 | .select(( 546 | comments::id, 547 | comments::parent, 548 | comments::text, 549 | comments::author, 550 | comments::email, 551 | comments::website, 552 | comments::hash, 553 | comments::created, 554 | comments::likes, 555 | comments::dislikes, 556 | )) 557 | .filter(comments::id.eq(id)) 558 | .first(conn) 559 | .chain_err(|| ErrorKind::DBRead)?; 560 | Ok(comment) 561 | } 562 | } 563 | 564 | #[derive(Serialize, Debug)] 565 | /// Subset of the comment which was just inserted. This data is needed to populate the frontend 566 | /// without calling for a complete refresh. 567 | pub struct InsertedComment { 568 | /// Primary key. 569 | id: i32, 570 | /// Parent comment. 571 | parent: Option, 572 | /// Commentors details. 573 | author: Option, 574 | } 575 | 576 | impl InsertedComment { 577 | /// Creates a new nested comment from a PrintedComment and a set of precalculated NestedComment children. 578 | fn new(comment: &PrintedComment) -> InsertedComment { 579 | let author = get_author(&comment.author, &comment.email, &comment.url); 580 | InsertedComment { 581 | id: comment.id, 582 | parent: comment.parent, 583 | author, 584 | } 585 | } 586 | } 587 | 588 | #[derive(Serialize, Debug)] 589 | /// Subset of the comment which was just edited. This data is needed to populate the frontend 590 | /// without calling for a complete refresh. 591 | pub struct CommentEdits { 592 | /// Primary key. 593 | id: i32, 594 | /// Commentors details. 595 | author: Option, 596 | /// Actual comment. 597 | text: String, 598 | /// Commentors indentifier. 599 | hash: String, 600 | } 601 | 602 | impl CommentEdits { 603 | /// Creates a new nested comment from a PrintedComment and a set of precalculated NestedComment children. 604 | fn new(comment: &PrintedComment) -> CommentEdits { 605 | let author = get_author(&comment.author, &comment.email, &comment.url); 606 | CommentEdits { 607 | id: comment.id, 608 | author, 609 | text: comment.text.to_owned(), 610 | hash: comment.hash.to_owned(), 611 | } 612 | } 613 | } 614 | 615 | #[derive(Serialize, Debug)] 616 | /// Subset of the comments table which is to be nested and sent to the frontend. 617 | pub struct NestedComment { 618 | /// Primary key. 619 | id: i32, 620 | /// Actual comment. 621 | text: String, 622 | /// Commentors author if given. 623 | author: Option, 624 | /// Commentors indentifier. 625 | hash: String, 626 | /// Timestamp of creation. 627 | created: DateTime, 628 | /// Comment children. 629 | children: Vec, 630 | /// Total number of votes. 631 | votes: i32, 632 | } 633 | 634 | impl NestedComment { 635 | /// Creates a new nested comment from a PrintedComment and a set of precalculated NestedComment children. 636 | fn new(comment: &PrintedComment, children: Vec) -> NestedComment { 637 | let date_time = DateTime::::from_utc(comment.created, Utc); 638 | let author = get_author(&comment.author, &comment.email, &comment.url); 639 | let votes = count_votes(comment.likes, comment.dislikes); 640 | NestedComment { 641 | id: comment.id, 642 | text: comment.text.to_owned(), 643 | author, 644 | hash: comment.hash.to_owned(), 645 | created: date_time, 646 | children, 647 | votes, 648 | } 649 | } 650 | 651 | /// Returns a list of all comments, nested, for a given post denoted via the `path` variable. 652 | pub fn list(conn: &SqliteConnection, path: &str) -> Result> { 653 | // Pull data from DB 654 | let comments = PrintedComment::list(conn, path)?; 655 | 656 | let mut graph = DiGraphMap::new(); 657 | let mut top_level_ids = Vec::new(); 658 | 659 | for comment in &comments { 660 | //For each comment, build a graph of parents and children 661 | graph.add_node(comment.id); 662 | 663 | //Generate edges if a relationship is found, stash as a root if not 664 | if let Some(parent_id) = comment.parent { 665 | graph.add_node(parent_id); 666 | graph.add_edge(parent_id, comment.id, ()); 667 | } else { 668 | top_level_ids.push(comment.id); 669 | } 670 | } 671 | 672 | //Run over all root comments, recursively filling their children as we go 673 | let tree: Vec<_> = top_level_ids 674 | .into_iter() 675 | .map(|id| build_tree(&graph, id, &comments)) 676 | .collect(); 677 | 678 | Ok(tree) 679 | } 680 | } 681 | 682 | /// Construct a nested comment tree from the flat indexed data obtained from the database. 683 | fn build_tree(graph: &DiGraphMap, id: i32, comments: &[PrintedComment]) -> NestedComment { 684 | let children: Vec = graph 685 | .neighbors(id) 686 | .map(|child_id| build_tree(graph, child_id, comments)) 687 | .collect(); 688 | 689 | //We can just unwrap here since the id value is always populated from a map over contents. 690 | let idx: usize = comments.iter().position(|c| c.id == id).unwrap(); 691 | 692 | if !children.is_empty() { 693 | NestedComment::new(&comments[idx], children) 694 | } else { 695 | NestedComment::new(&comments[idx], Vec::new()) 696 | } 697 | } 698 | 699 | /// Generates a value for author depending on the completeness of the author profile. 700 | fn get_author( 701 | author: &Option, 702 | email: &Option, 703 | url: &Option, 704 | ) -> Option { 705 | if author.is_some() { 706 | author.to_owned() 707 | } else if email.is_some() { 708 | //We want to parse the email address to keep it somewhat confidential. 709 | let real_email = email.to_owned().unwrap(); 710 | let at_index = real_email.find('@').unwrap_or_else(|| real_email.len()); 711 | let (user, domain) = real_email.split_at(at_index); 712 | let first_dot = domain.find('.').unwrap_or_else(|| domain.len()); 713 | let (_, trailing) = domain.split_at(first_dot); 714 | 715 | let mut email_obf = String::new(); 716 | email_obf.push_str(user); 717 | email_obf.push_str("@****"); 718 | email_obf.push_str(trailing); 719 | Some(email_obf) 720 | } else { 721 | //This can be something or nothing, since we don't need te parse it it doesn't matter 722 | url.to_owned() 723 | } 724 | } 725 | 726 | /// Calculates the total vote for a comment based on its likes and dislikes. 727 | fn count_votes(likes: Option, dislikes: Option) -> i32 { 728 | likes.unwrap_or_else(|| 0) - dislikes.unwrap_or_else(|| 0) 729 | } 730 | -------------------------------------------------------------------------------- /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | /// Comments table. 2 | pub mod comments; 3 | /// Preferences table. 4 | pub mod preferences; 5 | /// Threads table. 6 | pub mod threads; 7 | -------------------------------------------------------------------------------- /src/models/preferences/mod.rs: -------------------------------------------------------------------------------- 1 | use diesel; 2 | use diesel::prelude::*; 3 | use diesel::sqlite::SqliteConnection; 4 | 5 | use errors::*; 6 | use rand::distributions::Alphanumeric; 7 | use rand::{OsRng, Rng}; 8 | use schema::preferences; 9 | 10 | #[table_name = "preferences"] 11 | #[derive(Queryable, Identifiable)] 12 | #[primary_key(key)] 13 | /// Queryable, Identifiable reference to the preferences table. 14 | pub struct Preference { 15 | /// Key 16 | pub key: String, 17 | /// Value 18 | pub value: String, 19 | } 20 | 21 | impl Preference { 22 | /// Updates the sesssion key into the database only if the key does not exist. 23 | /// A default value is set in the migration schema and no other functions operate 24 | /// on this entry, so that should cover all bases. 25 | pub fn set_session(conn: &SqliteConnection) -> Result { 26 | use schema::preferences::dsl::*; //TODO: It'd be nice if we didn't have to double up here. 27 | 28 | let hash = session_hash().chain_err(|| ErrorKind::SessionHash)?; 29 | let session = preferences.filter(key.eq("session-key")); 30 | let result = diesel::update(session) 31 | .set(value.eq(hash)) 32 | .execute(conn) 33 | .is_ok(); 34 | Ok(result) 35 | } 36 | 37 | /// Returns the current session value from the database. 38 | pub fn get_session(conn: &SqliteConnection) -> Result { 39 | use schema::preferences::dsl::*; 40 | 41 | let session = preferences 42 | .filter(key.eq("session-key")) 43 | .limit(1) //This should always be the case, but just to be certain 44 | .load::(conn).chain_err(|| ErrorKind::DBRead)?; 45 | if session.len() == 1 { 46 | Ok(session[0].value.to_string()) 47 | } else { 48 | Err(ErrorKind::NoSession.into()) 49 | } 50 | } 51 | } 52 | 53 | /// Generates a random hash used as a session ID. 54 | fn session_hash() -> Result { 55 | Ok(OsRng::new() 56 | .chain_err(|| ErrorKind::Rand)? 57 | .sample_iter(&Alphanumeric) 58 | .take(24) 59 | .collect()) 60 | } 61 | -------------------------------------------------------------------------------- /src/models/threads/mod.rs: -------------------------------------------------------------------------------- 1 | use diesel; 2 | use diesel::prelude::*; 3 | use diesel::sqlite::SqliteConnection; 4 | 5 | use errors::*; 6 | use reqwest; 7 | use schema::threads; 8 | 9 | #[derive(Serialize, Queryable, Debug)] 10 | /// Queryable reference to the threads table. 11 | pub struct Thread { 12 | /// Primary key 13 | pub id: i32, 14 | /// URI to the thread 15 | pub uri: String, 16 | /// Thread title 17 | pub title: Option, 18 | } 19 | 20 | #[derive(Insertable, Debug)] 21 | #[table_name = "threads"] 22 | /// Insertable reference to the threads table. 23 | struct NewThread<'t> { 24 | /// URI to the thread. 25 | uri: &'t str, 26 | /// Thread title. 27 | title: Option<&'t str>, 28 | } 29 | 30 | /// Returns a thread ID given creation details about it. 31 | /// If the thread exists, an ID is returned directly, otherwise an entry 32 | /// is created for it first 33 | pub fn gen_or_get_id(conn: &SqliteConnection, host: &str, title: &str, path: &str) -> Result { 34 | match get_id(conn, path) { 35 | //TODO: Maybe the id is the same, but the title has been updated. 36 | Ok(id) => Ok(id), //Found an id, return it 37 | Err(err) => { 38 | match err { 39 | Error(ErrorKind::NoThread(_), _) => { 40 | verify_post(host, path)?; 41 | 42 | //We didn't find an id, but there was no error from the db. 43 | //Create one. 44 | let opt_title = if title.is_empty() { None } else { Some(title) }; 45 | 46 | let tid = create(conn, path, opt_title)?; 47 | Ok(tid) 48 | } 49 | _ => Err(err), 50 | } 51 | } 52 | } 53 | } 54 | 55 | /// Checks that the path posted actually exists on the host. 56 | /// Should minimise the injection attack surface. 57 | fn verify_post(host: &str, path: &str) -> Result<()> { 58 | // We use reqwest to handle the request for now, but may drop down to hyper later on. 59 | let res = reqwest::get(&format!("{}{}", host, path)).chain_err(|| ErrorKind::Request)?; 60 | 61 | if res.status() == reqwest::StatusCode::Ok { 62 | Ok(()) 63 | } else { 64 | Err(ErrorKind::PathCheckFailed.into()) 65 | } 66 | } 67 | 68 | /// Saves a new thread for URI into the database. Returns the id of the new record. 69 | fn create<'t>( 70 | conn: &SqliteConnection, 71 | new_url: &'t str, 72 | new_title: Option<&'t str>, 73 | ) -> Result { 74 | use schema::threads; 75 | 76 | let new_thread = NewThread { 77 | uri: new_url, 78 | title: new_title, 79 | }; 80 | 81 | let result = diesel::insert_into(threads::table) 82 | .values(&new_thread) 83 | .execute(conn) 84 | .is_ok(); 85 | 86 | if result { 87 | let thread_id = threads::table 88 | .select(threads::id) 89 | .order(threads::id.desc()) 90 | .first::(conn) 91 | .chain_err(|| ErrorKind::DBRead)?; 92 | Ok(thread_id) 93 | } else { 94 | Err(ErrorKind::DBInsert.into()) 95 | } 96 | } 97 | 98 | /// Returns the id of a thread from the database for a given URI. 99 | fn get_id(conn: &SqliteConnection, find_uri: &str) -> Result { 100 | use schema::threads::dsl::*; 101 | 102 | let thread_info = threads 103 | .filter(uri.eq(find_uri.to_string())) 104 | .load::(conn) 105 | .chain_err(|| ErrorKind::DBRead)?; 106 | if thread_info.len() == 1 { 107 | Ok(thread_info[0].id) 108 | } else { 109 | Err(ErrorKind::NoThread(find_uri.to_string()).into()) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/notify.rs: -------------------------------------------------------------------------------- 1 | use lettre::smtp::authentication::{Credentials, Mechanism}; 2 | use lettre::{EmailTransport, SmtpTransport}; 3 | use lettre_email::EmailBuilder; 4 | use reqwest; 5 | use std::collections::HashMap; 6 | 7 | use config::{Notifications, Telegram}; 8 | use data::FormInput; 9 | use errors::*; 10 | use regex::Regex; 11 | 12 | /// Parses a URL, returning just the domain portion. The regex is overkill for this at the moment, 13 | /// but I think it may be usefull in the future to have this ability. 14 | fn get_domain(host: &str) -> &str { 15 | lazy_static! { 16 | // Matches 4 groups: protocol, domain, port, path. 17 | static ref URLPARSE: Regex = Regex::new(r"(?i)(https?)://([^\s/?#_:]+\.?)+:?(\d+)?(/[^\s]*)?$").unwrap(); 18 | } 19 | let caps = URLPARSE.captures(host).unwrap(); 20 | 21 | caps.get(2).map_or("noreply", |m| m.as_str()) 22 | } 23 | 24 | /// Sends an email to a recipient listed in the configuration file when a new comment is posted, so 25 | /// long as the notification system is enabled (this check is elsewhere). 26 | pub fn send_notification( 27 | form: &FormInput, 28 | notify: &Notifications, 29 | host: &str, 30 | blog_name: &str, 31 | ip_addr: &str, 32 | ) -> Result<()> { 33 | let post_url = format!("{}{}", host.trim_right_matches('/'), form.path); 34 | let oration_addr = format!("oration@{}", get_domain(host)); 35 | let recipient_name = if notify.recipient.name == "~" { 36 | "Oration Admin".to_string() 37 | } else { 38 | notify.recipient.name.to_owned() 39 | }; 40 | 41 | let email = EmailBuilder::new() 42 | .to((notify.recipient.email.to_owned(), recipient_name)) 43 | .from((oration_addr, "Oration Watchdog")) 44 | .reply_to((form.sender_email(), form.sender_name())) 45 | .subject(format!("A new comment has been posted on {}", blog_name)) 46 | .text(format!( 47 | "A comment has been posted by {} on a post titled: {}. 48 | 49 | The comment reads: 50 | {} 51 | 52 | You may reply on your blog post ({}), or if the user has left an email address, responding to this message will deliver them an email. 53 | 54 | Debug information: 55 | {:?} 56 | 57 | Commenter's IP: {}", 58 | form.sender_name(), form.title, form.comment, post_url, form, ip_addr)) 59 | .build() 60 | .chain_err(|| ErrorKind::BuildEmail)?; 61 | 62 | // Connect to a remote server on a custom port 63 | let mut mailer = SmtpTransport::simple_builder(¬ify.smtp_server.host) 64 | .chain_err(|| ErrorKind::BuildSmtpTransport)? 65 | // Add credentials for authentication 66 | .credentials(Credentials::new(notify.smtp_server.user_name.to_owned(), notify.smtp_server.password.to_owned())) 67 | // Enable SMTPUTF8 if the server supports it 68 | .smtp_utf8(true) 69 | // Configure expected authentication mechanism 70 | .authentication_mechanism(Mechanism::Plain) 71 | .build(); 72 | 73 | mailer.send(&email).chain_err(|| ErrorKind::SendEmail)?; 74 | 75 | Ok(()) 76 | } 77 | 78 | /// Sends a push notification to a bot which will forward you a message containing the recent comment. 79 | pub fn push_telegram( 80 | form: &FormInput, 81 | telegram: &Telegram, 82 | host: &str, 83 | ip_addr: &str, 84 | ) -> Result<()> { 85 | let post_url = format!("{}{}", host.trim_right_matches('/'), form.path); 86 | let message = format!( 87 | "A comment has been posted by *{}* on a post titled: 88 | _{}_. 89 | 90 | The comment reads: 91 | {} 92 | 93 | You may reply on your blog post [here]({}). In the future, this bot may also provide a means of responding. 94 | 95 | Debug information: 96 | `{:?}` 97 | 98 | Commenter's IP: {}", 99 | form.sender_name(), form.title, form.comment, post_url, form, ip_addr); 100 | println!("{}", message); 101 | let preview = String::from("1"); 102 | let md = String::from("Markdown"); 103 | let mut params = HashMap::new(); 104 | params.insert("chat_id", &telegram.chat_id); 105 | params.insert("parse_mode", &md); 106 | params.insert("disable_web_page_preview", &preview); 107 | params.insert("text", &message); 108 | 109 | let res = reqwest::Client::new() 110 | .post(&format!( 111 | "https://api.telegram.org/bot{}/sendMessage", 112 | telegram.bot_id 113 | )) 114 | .form(¶ms) 115 | .send() 116 | .chain_err(|| ErrorKind::Request)?; 117 | 118 | if res.status() == reqwest::StatusCode::Ok { 119 | Ok(()) 120 | } else { 121 | Err(ErrorKind::TelegramNotify.into()) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/schema.rs: -------------------------------------------------------------------------------- 1 | table! { 2 | comments (id) { 3 | id -> Integer, 4 | tid -> Integer, 5 | parent -> Nullable, 6 | created -> Timestamp, 7 | modified -> Nullable, 8 | mode -> Integer, 9 | remote_addr -> Nullable, 10 | text -> Text, 11 | author -> Nullable, 12 | email -> Nullable, 13 | website -> Nullable, 14 | hash -> Text, 15 | likes -> Nullable, 16 | dislikes -> Nullable, 17 | voters -> Nullable, 18 | } 19 | } 20 | 21 | table! { 22 | preferences (key) { 23 | key -> Text, 24 | value -> Text, 25 | } 26 | } 27 | 28 | table! { 29 | threads (id) { 30 | id -> Integer, 31 | uri -> Text, 32 | title -> Nullable, 33 | } 34 | } 35 | 36 | joinable!(comments -> threads (tid)); 37 | allow_tables_to_appear_in_same_query!(comments, threads); 38 | -------------------------------------------------------------------------------- /src/static_files.rs: -------------------------------------------------------------------------------- 1 | use rocket::response::NamedFile; 2 | use std::path::{Path, PathBuf}; 3 | 4 | #[get("/")] 5 | /// Call serves any requested static file from public. 6 | fn files(file: PathBuf) -> Option { 7 | NamedFile::open(Path::new("public/").join(file)).ok() 8 | } 9 | -------------------------------------------------------------------------------- /src/tests.rs: -------------------------------------------------------------------------------- 1 | use super::rocket; 2 | use diesel::prelude::*; 3 | use rocket::http::Status; 4 | use rocket::local::Client; 5 | use schema::preferences::dsl::*; 6 | 7 | #[test] 8 | /// Tests connection to the database through the pool managed by rocket. 9 | fn db_connection() { 10 | let conn = rocket().1; 11 | 12 | let expected_keys = vec!["session-key"]; 13 | let actual_keys: Vec = preferences.select(key).load(&*conn).unwrap(); 14 | 15 | assert_eq!(expected_keys, actual_keys); 16 | } 17 | 18 | #[test] 19 | /// Compares the session hash in the database to the one returned by /session 20 | fn session_hash() { 21 | let (rocket, conn, _) = rocket(); 22 | let client = Client::new(rocket).expect("valid rocket instance"); 23 | let mut response = client.get("/oration/session").dispatch(); 24 | 25 | let session_key: Vec = preferences 26 | .filter(key.eq("session-key")) 27 | .select(value) 28 | .load(&*conn) 29 | .unwrap(); 30 | 31 | assert_eq!(response.status(), Status::Ok); 32 | assert_eq!(response.body_string().unwrap(), session_key[0]); 33 | } 34 | -------------------------------------------------------------------------------- /staging/config/nginx.conf: -------------------------------------------------------------------------------- 1 | user root; 2 | worker_processes 2; 3 | worker_rlimit_nofile 8192; 4 | 5 | error_log /var/log/nginx/error.log warn; 6 | pid /run/nginx.pid; 7 | 8 | events { 9 | worker_connections 2014; 10 | multi_accept on; 11 | use epoll; 12 | } 13 | 14 | http { 15 | include /etc/nginx/mime.types; 16 | default_type application/octet-stream; 17 | 18 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 19 | '$status $body_bytes_sent "$http_referer" ' 20 | '"$http_user_agent" "$http_x_forwarded_for"'; 21 | 22 | log_format timed '$remote_addr - $remote_user [$time_local] "$request" ' 23 | '$status $body_bytes_sent "$http_referer" ' 24 | '"$http_user_agent" "$http_x_forwarded_for" ' 25 | '$request_time $upstream_response_time $upstream_addr ' 26 | ' $upstream_status $upstream_cache_status $pipe'; 27 | 28 | access_log /var/log/nginx/access.log timed; 29 | 30 | sendfile on; 31 | tcp_nopush on; 32 | 33 | keepalive_timeout 30; 34 | 35 | gzip on; 36 | 37 | include /etc/nginx/conf.d/*.conf; 38 | include /etc/nginx/sites-enabled/*; 39 | } 40 | -------------------------------------------------------------------------------- /staging/config/nginx.vhost.conf: -------------------------------------------------------------------------------- 1 | server { 2 | client_max_body_size 20M; 3 | listen 80 default_server; 4 | server_name oration.local; 5 | 6 | root /vagrant/public_html/; 7 | add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com; style-src 'self' https://cdnjs.cloudflare.com 'unsafe-inline'; font-src 'self' https://cdnjs.cloudflare.com; img-src *"; 8 | add_header X-Frame-Options SAMEORIGIN; 9 | add_header X-Content-Type-Options nosniff; 10 | add_header X-XSS-Protection "1; mode=block"; 11 | add_header Referrer-Policy no-referrer-when-downgrade; 12 | 13 | location /oration { 14 | proxy_set_header Host $host; 15 | proxy_set_header X-Real-IP $remote_addr; 16 | proxy_set_header X-Forwarded-Host $host; 17 | proxy_set_header X-Forwarded-Server $host; 18 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 19 | proxy_set_header X-Forwarded-Proto $scheme; 20 | proxy_pass http://localhost:8000; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /staging/config/oration.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Oration Web Service 3 | Requires=network.target 4 | After=network.target 5 | 6 | [Service] 7 | ExecStart=/vagrant/app/oration 8 | WorkingDirectory=/vagrant/app 9 | User=vagrant 10 | Type=simple 11 | Restart=always 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /staging/prepare.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Oration Staging Build 4 | hosts: all 5 | tasks: 6 | # This step requries some manual intervention. Initially I was using the ekidd docker image, calling 7 | # `docker pull ekidd/rust-musl-builder:nightly`, but this no longer allows me to properly update the 8 | # nigtly version through `rustup update && cargo build`. Therefore, you need to pull the image from 9 | # github and build it yourself with the current nightly. Throw your image id at the end of the alias 10 | # and make sure your nightly is up to date before staging. 11 | - name: Backend | Build production executable 12 | shell: | 13 | alias rust-musl-builder='docker run --rm -t -v {{playbook_dir}}/..:/home/rust/src c93608479ed8' 14 | rust-musl-builder cargo build --release 15 | 16 | - name: Backend | Check for deployment directory 17 | file: 18 | path: deploy 19 | state: directory 20 | 21 | - name: Backend | Ready configuration file 22 | copy: 23 | src: ../oration.yaml 24 | dest: deploy/oration.yaml 25 | 26 | - name: Backend | Setting localhost as blog entrypoint 27 | replace: 28 | path: deploy/oration.yaml 29 | regexp: '^(host:\s+).*' 30 | replace: '\1http://localhost/' 31 | 32 | - name: Backend | Ready executable file 33 | copy: 34 | src: ../target/x86_64-unknown-linux-musl/release/oration 35 | dest: deploy/oration 36 | mode: u+rwx,g-wx,o-rwx 37 | 38 | - name: Database | Environment file 39 | copy: 40 | src: ../.env 41 | dest: deploy/.env 42 | 43 | - name: Database | Ready database 44 | copy: 45 | src: ../oration.db 46 | dest: deploy/oration.db 47 | 48 | - name: Frontend | Build Elm app 49 | command: npm run deploy 50 | args: 51 | chdir: ../app 52 | 53 | - name: Frontend | Finding map files in public area 54 | find: 55 | paths: ../public 56 | patterns: "*.map" 57 | recurse: yes 58 | register: map_files 59 | 60 | - name: Frontend | Removing map files from public area 61 | file: 62 | path: "{{ item.path }}" 63 | state: absent 64 | with_items: "{{ map_files.files }}" 65 | -------------------------------------------------------------------------------- /staging/services.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Oration Staging Server Services 4 | hosts: all 5 | become: true 6 | tasks: 7 | 8 | - name: Oration | Restart service daemon 9 | service: 10 | name: oration 11 | state: restarted 12 | enabled: yes 13 | 14 | - name: nginx | Restart service daemon 15 | service: 16 | name: nginx 17 | state: restarted 18 | enabled: yes 19 | 20 | -------------------------------------------------------------------------------- /staging/staging.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Oration Staging Server 4 | hosts: all 5 | become: true 6 | tasks: 7 | 8 | - name: Apt | Update and Upgrade 9 | apt: 10 | upgrade: yes 11 | update_cache: yes 12 | cache_valid_time: 86400 13 | 14 | - name: Apt | Install 15 | apt: 16 | name: "{{ item }}" 17 | state: latest 18 | with_items: 19 | - apt-transport-https 20 | - build-essential 21 | - pkg-config 22 | - libssl-dev 23 | - curl 24 | - git 25 | - nginx 26 | - vim 27 | - sqlite3 28 | - libsqlite3-dev 29 | 30 | - name: Oration | Deploy service file 31 | copy: 32 | src: config/oration.service 33 | dest: /etc/systemd/system/oration.service 34 | 35 | - name: nginx | Deploy nginx.conf 36 | copy: 37 | src: config/nginx.conf 38 | dest: /etc/nginx/nginx.conf 39 | 40 | - name: nginx | Delete default vhost 41 | file: 42 | path: /etc/nginx/sites-enabled/default 43 | state: absent 44 | 45 | - name: nginx | Deploy vhost config 46 | copy: 47 | src: config/nginx.vhost.conf 48 | dest: /etc/nginx/sites-available/oration.conf 49 | 50 | - name: nginx | Enable vhost 51 | file: 52 | src: /etc/nginx/sites-available/oration.conf 53 | dest: /etc/nginx/sites-enabled/000-oration 54 | state: link 55 | 56 | - name: nginx | Chmod logfile 57 | file: 58 | path: /var/log/nginx 59 | mode: "a+rx" 60 | state: directory 61 | recurse: true 62 | 63 | --------------------------------------------------------------------------------