Browse Source

Initial commit, version 1.0.0

tags/1.0.0
Neil 3 weeks ago
commit
64135ea63a
21 changed files with 1900 additions and 0 deletions
  1. 17
    0
      .gitignore
  2. 23
    0
      Cargo.toml
  3. 312
    0
      LICENSE
  4. 95
    0
      README.md
  5. BIN
      assets/Ubuntu-R.ttf
  6. BIN
      assets/background.jpg
  7. 283
    0
      assets/base.css
  8. BIN
      assets/hoster-logo.png
  9. 42
    0
      assets/logo.svg
  10. 137
    0
      lang.json
  11. 0
    0
      migrations/.gitkeep
  12. 1
    0
      migrations/create_links/down.sql
  13. 8
    0
      migrations/create_links/up.sql
  14. 46
    0
      src/cookies.rs
  15. 88
    0
      src/form.rs
  16. 121
    0
      src/link.rs
  17. 431
    0
      src/main.rs
  18. 188
    0
      src/templates.rs
  19. 7
    0
      templates/head.hbs
  20. 90
    0
      templates/home.hbs
  21. 11
    0
      templates/layout.hbs

+ 17
- 0
.gitignore View File

@@ -0,0 +1,17 @@
# ---> Rust
# Generated by Cargo
# will have compiled files and executables
/target/

# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock

# These are backup files generated by rustfmt
**/*.rs.bk

# Don't push Rocket's config file
Rocket.toml

# Don't push the DB
/db/db.sqlite

+ 23
- 0
Cargo.toml View File

@@ -0,0 +1,23 @@
[package]
name = "rs-short"
version = "1.0.0"
publish = false
edition = "2018"

[dependencies]
rocket = "0.4.2"
serde = "1.0"
serde_json = "1.0"
serde_derive = "1.0"
diesel = { version = "1.4", features = ["sqlite", "r2d2", "chrono"] }
diesel_migrations = "1.4"
rand = "0.7"
chrono = { version = "0.4", features = ["serde"] }
url = "2.1"
captcha = "0.0.7"
base64 = "0.10"

[dependencies.rocket_contrib]
version = "0.4.2"
default_features = false
features = ["handlebars_templates", "diesel_sqlite_pool", "serve", "json"]

+ 312
- 0
LICENSE View File

@@ -0,0 +1,312 @@
Mozilla Public License Version 2.0

1. Definitions

1.1. "Contributor" means each individual or legal entity that creates, contributes
to the creation of, or owns Covered Software.

1.2. "Contributor Version" means the combination of the Contributions of others
(if any) used by a Contributor and that particular Contributor's Contribution.

1.3. "Contribution" means Covered Software of a particular Contributor.

1.4. "Covered Software" means Source Code Form to which the initial Contributor
has attached the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case including portions
thereof.

1.5. "Incompatible With Secondary Licenses" means

(a) that the initial Contributor has attached the notice described in Exhibit
B to the Covered Software; or

(b) that the Covered Software was made available under the terms of version
1.1 or earlier of the License, but not also under the terms of a Secondary
License.

1.6. "Executable Form" means any form of the work other than Source Code Form.

1.7. "Larger Work" means a work that combines Covered Software with other
material, in a separate file or files, that is not Covered Software.

1.8. "License" means this document.

1.9. "Licensable" means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and all of the
rights conveyed by this License.

1.10. "Modifications" means any of the following:

(a) any file in Source Code Form that results from an addition to, deletion
from, or modification of the contents of Covered Software; or

(b) any new file in Source Code Form that contains any Covered Software.

1.11. "Patent Claims" of a Contributor means any patent claim(s), including
without limitation, method, process, and apparatus claims, in any patent Licensable
by such Contributor that would be infringed, but for the grant of the License,
by the making, using, selling, offering for sale, having made, import, or
transfer of either its Contributions or its Contributor Version.

1.12. "Secondary License" means either the GNU General Public License, Version
2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those licenses.

1.13. "Source Code Form" means the form of the work preferred for making modifications.

1.14. "You" (or "Your") means an individual or a legal entity exercising rights
under this License. For legal entities, "You" includes any entity that controls,
is controlled by, or is under common control with You. For purposes of this
definition, "control" means (a) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or otherwise,
or (b) ownership of more than fifty percent (50%) of the outstanding shares
or beneficial ownership of such entity.

2. License Grants and Conditions

2.1. Grants

Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive
license:

(a) under intellectual property rights (other than patent or trademark) Licensable
by such Contributor to use, reproduce, make available, modify, display, perform,
distribute, and otherwise exploit its Contributions, either on an unmodified
basis, with Modifications, or as part of a Larger Work; and

(b) under Patent Claims of such Contributor to make, use, sell, offer for
sale, have made, import, and otherwise transfer either its Contributions or
its Contributor Version.

2.2. Effective Date

The licenses granted in Section 2.1 with respect to any Contribution become
effective for each Contribution on the date the Contributor first distributes
such Contribution.

2.3. Limitations on Grant Scope

The licenses granted in this Section 2 are the only rights granted under this
License. No additional rights or licenses will be implied from the distribution
or licensing of Covered Software under this License. Notwithstanding Section
2.1(b) above, no patent license is granted by a Contributor:

(a) for any code that a Contributor has removed from Covered Software; or

(b) for infringements caused by: (i) Your and any other third party's modifications
of Covered Software, or (ii) the combination of its Contributions with other
software (except as part of its Contributor Version); or

(c) under Patent Claims infringed by Covered Software in the absence of its
Contributions.

This License does not grant any rights in the trademarks, service marks, or
logos of any Contributor (except as may be necessary to comply with the notice
requirements in Section 3.4).

2.4. Subsequent Licenses

No Contributor makes additional grants as a result of Your choice to distribute
the Covered Software under a subsequent version of this License (see Section
10.2) or under the terms of a Secondary License (if permitted under the terms
of Section 3.3).

2.5. Representation

Each Contributor represents that the Contributor believes its Contributions
are its original creation(s) or it has sufficient rights to grant the rights
to its Contributions conveyed by this License.

2.6. Fair Use

This License is not intended to limit any rights You have under applicable
copyright doctrines of fair use, fair dealing, or other equivalents.

2.7. Conditions

Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in
Section 2.1.

3. Responsibilities

3.1. Distribution of Source Form

All distribution of Covered Software in Source Code Form, including any Modifications
that You create or to which You contribute, must be under the terms of this
License. You must inform recipients that the Source Code Form of the Covered
Software is governed by the terms of this License, and how they can obtain
a copy of this License. You may not attempt to alter or restrict the recipients'
rights in the Source Code Form.

3.2. Distribution of Executable Form

If You distribute Covered Software in Executable Form then:

(a) such Covered Software must also be made available in Source Code Form,
as described in Section 3.1, and You must inform recipients of the Executable
Form how they can obtain a copy of such Source Code Form by reasonable means
in a timely manner, at a charge no more than the cost of distribution to the
recipient; and

(b) You may distribute such Executable Form under the terms of this License,
or sublicense it under different terms, provided that the license for the
Executable Form does not attempt to limit or alter the recipients' rights
in the Source Code Form under this License.

3.3. Distribution of a Larger Work

You may create and distribute a Larger Work under terms of Your choice, provided
that You also comply with the requirements of this License for the Covered
Software. If the Larger Work is a combination of Covered Software with a work
governed by one or more Secondary Licenses, and the Covered Software is not
Incompatible With Secondary Licenses, this License permits You to additionally
distribute such Covered Software under the terms of such Secondary License(s),
so that the recipient of the Larger Work may, at their option, further distribute
the Covered Software under the terms of either this License or such Secondary
License(s).

3.4. Notices

You may not remove or alter the substance of any license notices (including
copyright notices, patent notices, disclaimers of warranty, or limitations
of liability) contained within the Source Code Form of the Covered Software,
except that You may alter any license notices to the extent required to remedy
known factual inaccuracies.

3.5. Application of Additional Terms

You may choose to offer, and to charge a fee for, warranty, support, indemnity
or liability obligations to one or more recipients of Covered Software. However,
You may do so only on Your own behalf, and not on behalf of any Contributor.
You must make it absolutely clear that any such warranty, support, indemnity,
or liability obligation is offered by You alone, and You hereby agree to indemnify
every Contributor for any liability incurred by such Contributor as a result
of warranty, support, indemnity or liability terms You offer. You may include
additional disclaimers of warranty and limitations of liability specific to
any jurisdiction.

4. Inability to Comply Due to Statute or Regulation

If it is impossible for You to comply with any of the terms of this License
with respect to some or all of the Covered Software due to statute, judicial
order, or regulation then You must: (a) comply with the terms of this License
to the maximum extent possible; and (b) describe the limitations and the code
they affect. Such description must be placed in a text file included with
all distributions of the Covered Software under this License. Except to the
extent prohibited by statute or regulation, such description must be sufficiently
detailed for a recipient of ordinary skill to be able to understand it.

5. Termination

5.1. The rights granted under this License will terminate automatically if
You fail to comply with any of its terms. However, if You become compliant,
then the rights granted under this License from a particular Contributor are
reinstated (a) provisionally, unless and until such Contributor explicitly
and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor
fails to notify You of the non-compliance by some reasonable means prior to
60 days after You have come back into compliance. Moreover, Your grants from
a particular Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the first
time You have received notice of non-compliance with this License from such
Contributor, and You become compliant prior to 30 days after Your receipt
of the notice.

5.2. If You initiate litigation against any entity by asserting a patent infringement
claim (excluding declaratory judgment actions, counter-claims, and cross-claims)
alleging that a Contributor Version directly or indirectly infringes any patent,
then the rights granted to You by any and all Contributors for the Covered
Software under Section 2.1 of this License shall terminate.

5.3. In the event of termination under Sections 5.1 or 5.2 above, all end
user license agreements (excluding distributors and resellers) which have
been validly granted by You or Your distributors under this License prior
to termination shall survive termination.

6. Disclaimer of Warranty

Covered Software is provided under this License on an "as is" basis, without
warranty of any kind, either expressed, implied, or statutory, including,
without limitation, warranties that the Covered Software is free of defects,
merchantable, fit for a particular purpose or non-infringing. The entire risk
as to the quality and performance of the Covered Software is with You. Should
any Covered Software prove defective in any respect, You (not any Contributor)
assume the cost of any necessary servicing, repair, or correction. This disclaimer
of warranty constitutes an essential part of this License. No use of any Covered
Software is authorized under this License except under this disclaimer.

7. Limitation of Liability

Under no circumstances and under no legal theory, whether tort (including
negligence), contract, or otherwise, shall any Contributor, or anyone who
distributes Covered Software as permitted above, be liable to You for any
direct, indirect, special, incidental, or consequential damages of any character
including, without limitation, damages for lost profits, loss of goodwill,
work stoppage, computer failure or malfunction, or any and all other commercial
damages or losses, even if such party shall have been informed of the possibility
of such damages. This limitation of liability shall not apply to liability
for death or personal injury resulting from such party's negligence to the
extent applicable law prohibits such limitation. Some jurisdictions do not
allow the exclusion or limitation of incidental or consequential damages,
so this exclusion and limitation may not apply to You.

8. Litigation

Any litigation relating to this License may be brought only in the courts
of a jurisdiction where the defendant maintains its principal place of business
and such litigation shall be governed by laws of that jurisdiction, without
reference to its conflict-of-law provisions. Nothing in this Section shall
prevent a party's ability to bring cross-claims or counter-claims.

9. Miscellaneous

This License represents the complete agreement concerning the subject matter
hereof. If any provision of this License is held to be unenforceable, such
provision shall be reformed only to the extent necessary to make it enforceable.
Any law or regulation which provides that the language of a contract shall
be construed against the drafter shall not be used to construe this License
against a Contributor.

10. Versions of the License

10.1. New Versions

Mozilla Foundation is the license steward. Except as provided in Section 10.3,
no one other than the license steward has the right to modify or publish new
versions of this License. Each version will be given a distinguishing version
number.

10.2. Effect of New Versions

You may distribute the Covered Software under the terms of the version of
the License under which You originally received the Covered Software, or under
the terms of any subsequent version published by the license steward.

10.3. Modified Versions

If you create software not governed by this License, and you want to create
a new license for such software, you may create and use a modified version
of this License if you rename the license and remove any references to the
name of the license steward (except to note that such modified license differs
from this License).

10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses

If You choose to distribute Source Code Form that is Incompatible With Secondary
Licenses under the terms of this version of the License, the notice described
in Exhibit B of this License must be attached. Exhibit A - Source Code Form
License Notice

This Source Code Form is subject to the terms of the Mozilla Public License,
v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain
one at http://mozilla.org/MPL/2.0/.

If it is not possible or desirable to put the notice in a particular file,
then You may include the notice in a location (such as a LICENSE file in a
relevant directory) where a recipient would be likely to look for such a notice.

You may add additional accurate notices of copyright ownership.

Exhibit B - "Incompatible With Secondary Licenses" Notice

This Source Code Form is "Incompatible With Secondary Licenses", as defined
by the Mozilla Public License, v. 2.0.

+ 95
- 0
README.md View File

@@ -0,0 +1,95 @@
# rs-short

Link shortener in Rust.

Developed to be as minimalist and lightweight as possible.

Powered by the [Rocket](https://rocket.rs) framework using (server-side) Handlebars templates.

- Less than 1000 lines of code, including 20% of comments
- Consumes between 5MB and 20MB of RAM
- No JS
- No CSS framework ; CSS is handmade and all rules are prefixed to avoid rule conflicts
- No tracking features at all
- No `unsafe` block

Features:
- Includes a captcha as a minimal protection against spamming
- Easily customizable assets
- Only needs a SQLite database to work
- Localization (available in French and English)
- Counting clicks
- Allows shortcut deletion

**Official instance:** https://s.42l.fr/

## Running an instance

First, you must install Cargo and the latest stable version of Rust by following the instructions on [this website](https://rustup.rs/). Alternatively, you can use the [liuchong/rustup](https://hub.docker.com/r/liuchong/rustup) Docker image.

- Clone the project:

```bash
git clone https://git.42l.fr/42l/rs-short.git
```

- Edit what you need. You might want to change the following elements:
- constant `INSTANCE_HOSTNAME` in `main.rs`: replace with your instance hostname
- constant `HOSTER_HOSTNAME` in `main.rs`: replace with the hostname of the organization hosting the project
- `assets/hoster-logo.png`: replace with the logo of your organization
- `assets/logo.svg`: the software logo
- `assets/background.jpg`: the default background

- Create a file named `Rocket.toml` at the project root, containing the following:

```toml
[global]
address = "<ADDRESS>"
template_dir = "templates"
secret_key = "<SECRET KEY>"

[global.databases.sqlite_database]
url = "db/db.sqlite"
```

- Replace `<ADDRESS>` by the address to listen on
- Replace `<SECRET KEY>` by the result of the command `openssl rand -base64 32`
- Eventually change the database storage path.
You can specify more parameters following the [Rocket documentation](https://api.rocket.rs/v0.4/rocket/config/index.html).

- `cargo run --release`

## Contributing

The initial version of the software has been developed in one week ; there's still a lot to do.

Here are many ways to contribute:
- Translate!
- Add your entries in the `lang.json` file.
- Once you're done, edit `templates.rs` and add your language in the ValidLanguages structure.
- Improve the software modularity
- Add a configuration file
- Configure instance and hoster's hostname from the configuration file
- Toggle captcha
- Add postgresql compatibility
- Add different CSS themes (a dark theme would be a great start!)
- Develop a more resilient protection to spambots
- Blacklist hostnames and shortcut names ?
- Ban / ratelimit IPs ? (I'd say this task isn't up to rs-short, but rather to a `fail2ban` instance)
- Clean up the code
- Restructure the rocket routes in `main.rs` to something more readable
- Make a better usage of template contexts
- Improve the forms if you're knowledgeable in Rocket forms
- Separate the code into more files if necessary

This software is mainly developed and maintained by [Neil](https://shelter.moe/@Neil) for the [Association 42l](https://42l.fr).

If you like the work done on this project, please consider to [donate or join](https://42l.fr/Support-us) the association. Thank you!


## Graphical credits

- Link Shortener logo by [Brume](https://shelter.moe/@Brume).
- Link Shortener logo font is Hylia Serif by [Artsy Omni](http://artsyomni.com/hyliaserif).
- Default background by [Love-Kay on deviantart](https://www.deviantart.com/love-kay/art/Abstract-Colorful-Watercolor-Texture-438376516).
- Website font by [Ubuntu](https://design.ubuntu.com/font/)

BIN
assets/Ubuntu-R.ttf View File


BIN
assets/background.jpg View File


+ 283
- 0
assets/base.css View File

@@ -0,0 +1,283 @@
.s-backbutton {
position: absolute;
top: 10px;
left: 10px;
display: flex;
font-size: 14px;
padding: 0.5rem;
}

.s-backbutton img {
width: 3rem;
height: 3rem;
margin: auto;
padding-right: 0.5rem;
}

/* Full-sized elements in container */
.s-section {
width: 100%;
}

/* Center elements in a container that takes almost half of the page */
.s-field {
width: 40%;
margin-left: auto;
margin-right: auto;
}

/* Smaller label */
.s-field > label {
font-size: 13px;
}

/* Stylish text inputs. No borders, big font size. */
.s-field > input {
background-color: transparent;
border: none;
border-bottom: 1px solid #999999;
outline: none;
height: 3rem;
width: 100%;
font-size: 16px;
}

/* stylish - material design - form button. */
.s-button {
width: 100%;
color: #FFF;
box-shadow: 0 0 2px rgba(0,0,0,.12),0 2px 2px rgba(0,0,0,.2);
font-weight: 500;
font-size: .875rem;
text-transform: uppercase;
transition: all .2s ease-in-out;
border: none;
border-radius: 2px;
height: 2.25rem;
text-align: center;
line-height: 2.25rem;
vertical-align: middle;
white-space: nowrap;
cursor: pointer;
text-decoration: none;
display: block;
margin: auto;
}

.s-link .s-button {
width: 90%;
}

.s-button.s-primary {
background-color: #44C3EA;
}

/* lighter color when the button is hovered */
.s-button.s-primary > button:hover {
background-color: #5FCBEC;

}

/* Centering the captcha in the the left container */
.s-captcha-img {
text-align: center;
margin-left: auto;
margin-right: auto;
opacity: 0.75;
}

.s-captcha-img img {
max-width: 80vw;
}

/* Applying flex properties to the captcha container
* so we can add the captcha field on the same line
* while keeping a responsive page. */
.s-captcha-section {
display: flex;
flex-wrap: wrap;
}

/* Centering the captcha container (right)
* and reducing its size (no many characters to copy) */
.s-captcha-field {
margin-top: auto;
margin-bottom: auto;
width: 40%;
}

/* No many characters in the field, so bigger characters. */
.s-captcha-field > input {
font-size: 26px;
}

/* Let's have a centered logo. */
.s-logo {
text-align: center;
}

.s-logo img {
width: 40%;
}

.s-footer {
text-align: center;
font-size: 14px;
}

.s-footer a {
text-decoration: none;
}

/* Using space empty blocks for visibility around the logo. */
.s-space {
height: 6vh;
}

/* Material design notification.
* Applying the same size properties as the other elements.
* Kinda hackish. See Keyframes. */
.s-notification {
display: inline-block;
margin: 0px auto;
margin-bottom: 30px;
text-align: center;
border-radius: 2px;
box-shadow: none;
border: none;
color: #fff;
padding: 15px 0px;
width: 100%;
}

/* Success notification color. */
.s-notification.s-success {
background-color: #2DAA29;
animation: fadeinout ease-in-out 8s;
top: -200px;
position: fixed;
}

/* Success notification color. */
.s-notification.s-link {
border-left: 6px solid #44C3EA;
background-color: #71b2ff1a;
color: black;
display: block;
box-shadow: 0 0 2px rgba(0,0,0,.12),0 2px 2px rgba(0,0,0,.2);

/* #44C3EA */
}

.s-field.s-link-group {
width: 95%;
display: flex;
flex-wrap: wrap;
}

.s-field.s-link-group label {
min-width: 8rem;
line-height: 32px;
padding-right: 8px;
text-align: right;
margin: auto;
}

.s-field.s-link-group input {
height: 2rem;
font-size: 12px;
width: 65%;
margin: auto;
}

.s-link small {
color: #646464;
font-style: italic;
}

.s-button.s-danger {
background-color: #EE3838;
}

/* Failure notification color. */
.s-notification.s-error {
background-color: #F44336;
animation: fadeinout ease-in-out 8s;
top: -200px;
position: fixed;
}

/* Some hack to make it fade in and out without Javascript.
* The animation lasts 8 seconds, appears at 2%, disappears at 99%.
* Then, the element is moved out of the browser window. */
@keyframes fadeinout {
0% {
opacity: 0;
transform: translate3d(0, -100%, 0);
position: unset;
top: unset;
}
2% {
opacity: 1;
transform: none;
}
95% {
opacity: 1;
}
99% {
opacity: 0;
position: unset;
top: unset;
}
100% {
opacity: 0;
top: -200px;
position: fixed;
}
}

/* for mobile and tablets. */
@media only screen and (max-width: 880px) {
/* use full-sized elements. */
.s-field {
width: 95%;
}
/* except for the captcha field which doesn't need
* any resizing. BTW it's in a flex (wrap) container
* so the element is moved in case it's too big. */
.s-captcha-field {
width: 40%;
margin-left: auto;
margin-right: auto;
}

.s-logo {
padding-top: 2rem;
}

.s-logo img {
width: 100%;
}
}

/* This font is cool. */
@font-face {
font-family: 'Ubuntu-R';
src: url('/assets/Ubuntu-R.ttf');
font-weight: normal;
font-style: normal;
}

/* Applying this font EVERYWHERE. */
* {
font-family: "Ubuntu-R"
}

/* The background image */
body {
background-image: url("/assets/background.jpg");
background-repeat: no-repeat;
background-size: cover;
background-position: top;
background-attachment: fixed;
}

BIN
assets/hoster-logo.png View File


+ 42
- 0
assets/logo.svg
File diff suppressed because it is too large
View File


+ 137
- 0
lang.json View File

@@ -0,0 +1,137 @@
{
"pages": {
"home": {
"template": "home",
"lang": {
"back_to_hoster": {
"En": "Back to 42l's website",
"Fr": "Retour sur le site web de 42l"
},
"hoster_logo_alt": {
"En": "Association 42l",
"Fr": "Association 42l"
},
"logo_alt": {
"En": "Link Shortener",
"Fr": "Raccourcisseur de liens"
},
"title": {
"En": "42l link shortener",
"Fr": "Raccourcisseur de liens 42l"
},
"desc" : {
"En": "42l Link shortener service",
"Fr": "Service raccourcisseur de liens 42l"
},
"captcha_alt": {
"En": "Captcha",
"Fr": "Captcha"
},
"url_to_placeholder": {
"En": "Enter your long URL here...",
"Fr": "Entrez votre longue URL ici..."
},
"url_from_placeholder": {
"En": "Custom shortcut name (optional)",
"Fr": "Nom personnalisé de votre raccourci (facultatif)"
},
"url_to_label": {
"En": "URL to shortcut",
"Fr": "URL à raccourcir"
},
"url_from_label": {
"En": "Shortcut name",
"Fr": "Nom du raccourci"
},
"captcha_placeholder": {
"En": "Captcha",
"Fr": "Captcha"
},
"captcha_label": {
"En": "Anti-spam check",
"Fr": "Vérification anti-spam"
},
"button_submit_text": {
"En": "Create!",
"Fr": "Créer !"
},
"error_invalid_link": {
"En": "The link you tried to visit has been deleted or doesn't exist.",
"Fr": "Le lien que vous avez essayé de visiter a été supprimé ou n'existe pas."
},
"error_session_expired": {
"En": "Your session has expired, please retry.",
"Fr": "Votre session a expiré, veuillez réessayer."
},
"error_captcha_fail": {
"En": "You failed the captcha, please retry.",
"Fr": "Vous avez raté le captcha, veuillez réessayer."
},
"error_link_already_exists": {
"En": "This shortcut name is already taken, please take another one.",
"Fr": "Ce nom de raccourci est déjà pris, veuillez en trouver un autre."
},
"error_cookie_parse_fail": {
"En": "We failed to read your cookie. That shouldn't happen, but please retry.",
"Fr": "Nous n'avons pas réussi à lire votre cookie. Cela ne devrait pas se produire, mais veuillez réessayer."
},
"error_db_fail": {
"En": "We failed to establish a connection with the database. That's quite unexpected, please contact us.",
"Fr": "Nous n'avons pas pu établir de lien avec la base de données. C'est assez inattendu, veuillez nous contacter."
},
"error_invalid_form": {
"En": "Some of the fields are invalid. Please retry.",
"Fr": "La plupart des champs sont invalides. Veuillez réessayer."
},
"error_link_not_found": {
"En": "The link you're searching for doesn't exist or has been deleted.",
"Fr": "Le lien que vous recherchez n'existe pas ou a été supprimé."
},
"error_invalid_key": {
"En": "The link administration key you specified is invalid.",
"Fr": "La clé d'administration du lien que vous avez spécifiée est invalide."
},
"error_link_delete_db_fail": {
"En": "Sorry, we failed to delete your link. This is a problem from our side, please reach us.",
"Fr": "Désolés, nous avons échoué à supprimer votre lien. Il s'agit d'un problème de notre côté, veuillez nous contacter."
},
"form_success": {
"En": "Your link has been successfully created!",
"Fr": "Votre lien a été créé avec succès !"
},
"link_delete_success": {
"En": "Your link has been successfully deleted!",
"Fr": "Votre lien a été supprimé avec succès !"
},
"notif_link_label_shortcut": {
"En": "Your shortcut",
"Fr": "Votre raccourci"
},
"notif_link_label_original": {
"En": "Original link",
"Fr": "Lien original"
},
"notif_link_label_admin": {
"En": "Administration link",
"Fr": "Lien d'administration"
},
"notif_link_admin_desc": {
"En": "This link allows you to check the link counter and delete the shortcut.",
"Fr": "Ce lien vous permet de compter le nombre de clics et de supprimer le raccourci."
},
"notif_link_clickcount": {
"En": "Click count: ",
"Fr": "Nombre de clics : "
},
"notif_link_delete": {
"En": "Delete link",
"Fr": "Supprimer le lien"
},
"footer_source_code": {
"En": "Source code, license and credits",
"Fr": "Code source, licence et crédits"
}
}
}
}
}

+ 0
- 0
migrations/.gitkeep View File


+ 1
- 0
migrations/create_links/down.sql View File

@@ -0,0 +1 @@
DROP TABLE links

+ 8
- 0
migrations/create_links/up.sql View File

@@ -0,0 +1,8 @@
CREATE TABLE links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_from VARCHAR NOT NULL,
url_to VARCHAR NOT NULL,
key BLOB NOT NULL,
time TIMESTAMP NOT NULL,
clicks INTEGER NOT NULL DEFAULT 0
);

+ 46
- 0
src/cookies.rs View File

@@ -0,0 +1,46 @@
// Rocket uses
use rocket::http::{Cookie, Cookies};

// chrono uses
use chrono::{NaiveDateTime, Utc};

pub const COOKIE_CAPTCHA: &str = "captcha_key";

// shall be called at the end of every single GET or POST request.
pub fn cookie_captcha_set(captcha_answer: &str, mut cookies: Cookies) {
cookies.add_private(Cookie::new(COOKIE_CAPTCHA,
format!("{}|{}",
Utc::now().naive_utc().format("%s"),
captcha_answer)));
}

pub fn cookie_captcha_get(cookies: &mut Cookies) -> Option<(NaiveDateTime, String)> {
match cookies.get_private(COOKIE_CAPTCHA) {
Some(c) => {
let splitted_cookie: Vec<&str> = c.value().split('|').collect();
if splitted_cookie.len() == 2 {
match NaiveDateTime::parse_from_str(splitted_cookie[0], "%s") {
Ok(d) => {
// returning a tuple (DateTime, captcha_answer)
Some((d, String::from(splitted_cookie[1])))
},
Err(e) => {
// shouldn't happen unless the user is able to forge their private cookies
// or there was a problem during cookie writing
eprintln!("WARN: cookie_captcha_get: failed to parse DateTime: {:?}", e);
None
}
}
}
else {
// shouldn't happen either (see above)
eprintln!("WARN: cookie_captcha_get: cookie is not formatted correctly");
None
}
}
None => {
eprintln!("WARN: The user has no cookie, or failed to read cookie.");
None
}
}
}

+ 88
- 0
src/form.rs View File

@@ -0,0 +1,88 @@
// General input checking is implemented here.

// url uses
use url::Url;

// rocket uses
use rocket::http::RawStr;
use rocket::request::FromFormValue;

// Form structure. The following fields must be present.
#[derive(FromForm, Serialize, Clone, Debug)]
pub struct LinksForm {
pub url_from: Option<InputUrlCustomText>,
pub url_to: Option<InputUrl>,
pub captcha: Option<InputCaptcha>,
}

// used to remove all of this Option<> crap
pub struct ValidLink {
pub url_from: InputUrlCustomText,
pub url_to: InputUrl,
pub captcha: InputCaptcha,
}

// converting a LinksForm to a ValidLink
impl LinksForm {
pub fn is_valid(&self) -> Option<ValidLink> {
Some(ValidLink {
url_from: self.url_from.clone()?,
url_to: self.url_to.clone()?,
captcha: self.captcha.clone()?,
})
}
}

#[derive(Serialize, PartialEq, Debug, Clone)]
pub struct InputUrlCustomText(pub String);

#[derive(Serialize, PartialEq, Debug, Clone)]
pub struct InputUrl(pub String);

#[derive(Serialize, PartialEq, Debug, Clone)]
pub struct InputCaptcha(pub String);

// url_from Form value
// url_from is the custom text set for the link.
// A valid url_from value must have between 0 and 80 characters.
impl<'v> FromFormValue<'v> for InputUrlCustomText {

type Error = &'v RawStr;
fn from_form_value(form_value: &'v RawStr) -> Result<InputUrlCustomText, &'v RawStr> {
match form_value.url_decode_lossy().len() <= 50 {
true => Ok(InputUrlCustomText(form_value.url_decode_lossy())),
false => Err(form_value),
}
}
}

// url_to Form value
// url_to is the URL users gets redirected.
// A valid url_to must be parsed successfully by the url crate.
impl<'v> FromFormValue<'v> for InputUrl {

type Error = &'v RawStr;
fn from_form_value(form_value: &'v RawStr) -> Result<InputUrl, &'v RawStr> {
match Url::parse(&form_value.url_decode_lossy()) {
Ok(r) => Ok(InputUrl(r.into_string())),
Err(_) => Err(form_value),
}
}
}

// captcha Form value
// captcha is the field filled when the user has to resolve the captcha.
// A valid captcha must be between 4 and 8 characters long.
// Then, the captcha value must be checked.
impl<'v> FromFormValue<'v> for InputCaptcha {

type Error = &'v RawStr;
fn from_form_value(form_value: &'v RawStr) -> Result<InputCaptcha, &'v RawStr> {
match form_value.url_decode_lossy().len() >= 4
&& form_value.url_decode_lossy().len() <= 8 {
true => Ok(InputCaptcha(form_value.url_decode_lossy())),
false => Err(form_value),
}
}
}


+ 121
- 0
src/link.rs View File

@@ -0,0 +1,121 @@
// diesel uses
use diesel::{self, prelude::*};

// chrono uses
use chrono::prelude::*;

// base64 uses
use base64::{encode_config as base64_encode_config};
use base64::URL_SAFE_NO_PAD;

// RNG uses
use rand::RngCore;
use rand::rngs::OsRng;

// Links database uses
use self::schema::links;
use self::schema::links::dsl::{links as all_links};

// local uses
use crate::INSTANCE_HOSTNAME;

// The Links Table, as seen from the database
mod schema {
table! {
links (id) {
id -> Nullable<Integer>,
url_from -> Text,
url_to -> Text,
key -> Binary,
time -> Timestamp,
clicks -> Integer,
}
}
}


#[table_name="links"]
#[derive(Serialize, Queryable, Insertable, Debug, Clone)]
pub struct Link {
pub id: Option<i32>,
pub url_from: String,
pub url_to: String,
pub key: Vec<u8>,
pub time: NaiveDateTime,
pub clicks: i32,
}

#[derive(Serialize)]
pub struct LinkInfo {
pub url_from: String,
pub url_to: String,
pub adminlink: String,
pub clicks: i32,
}

// used to format data into the right URLs in link admin panel
impl LinkInfo {
pub fn create_from(link: Link) -> Self {
LinkInfo {
url_from: format!("https://{}/{}", INSTANCE_HOSTNAME, link.url_from),
url_to: link.url_to,
adminlink: format!("https://{}/{}/{}", INSTANCE_HOSTNAME,
link.url_from,
base64_encode_config(&link.key, URL_SAFE_NO_PAD)),
clicks: link.clicks
}
}
}

// methods used to query the DB
impl Link {
// gets *all links* (is this even used somewhere?)
pub fn all(conn: &SqliteConnection) -> Vec<Link> {
all_links.order(links::id.desc()).load::<Link>(conn).unwrap()
}

pub fn get_link(i_url_from: &str, conn: &SqliteConnection) -> Option<Link> {
all_links.filter(links::url_from.eq(i_url_from)).first(conn).ok()
}

// click count increment
pub fn increment_by_id(selected_link: &Link, conn: &SqliteConnection) -> bool {
diesel::update(all_links.find(selected_link.id))
.set(links::clicks.eq(selected_link.clicks + 1)).execute(conn).is_ok()
}

// creating a new link
pub fn insert(i_url_from: String, i_url_to: String, conn: &SqliteConnection) -> Option<Link> {
let t = Link {
id: None,
url_from: i_url_from,
url_to: i_url_to,
time: Utc::now().naive_utc(),
key: gen_random(24),
clicks: 0,
};
match diesel::insert_into(links::table).values(&t).execute(conn).is_ok() {
true => Some(t),
false => None,
}
}

// deleting a link with its ID
pub fn delete_by_id(id: i32, conn: &SqliteConnection) -> bool {
diesel::delete(all_links.find(id)).execute(conn).is_ok()
}
}

// used to generate random strings for:
// - link admin panel (links.key field, 24 bytes)
// - short link names when none is specified (links.url_from field, 6 bytes)
pub fn gen_random(n_bytes: usize) -> Vec<u8>
{
// Using /dev/random to generate random bytes
let mut r = OsRng;

let mut my_secure_bytes = vec![0u8; n_bytes];
r.fill_bytes(&mut my_secure_bytes);
my_secure_bytes
}


+ 431
- 0
src/main.rs View File

@@ -0,0 +1,431 @@
#![feature(proc_macro_hygiene, decl_macro)]

#[macro_use] extern crate rocket;
#[macro_use] extern crate diesel;
#[macro_use] extern crate diesel_migrations;
#[macro_use] extern crate serde_derive;
#[macro_use] extern crate rocket_contrib;
extern crate rand;
extern crate chrono;
extern crate url;
extern crate captcha;
extern crate base64;

mod link;
mod templates;
mod form;
mod cookies;
//#[cfg(test)] mod tests;

// rocket uses
use rocket::{Rocket, State};
use rocket::http::Cookies;
use rocket::fairing::AdHoc;
use rocket::request::{Form};
use rocket::response::{Redirect};
use rocket_contrib::{templates::Template, serve::StaticFiles};

// diesel uses
use diesel::SqliteConnection;

// local uses
use link::Link;
use link::LinkInfo;
use cookies::cookie_captcha_set;
use cookies::cookie_captcha_get;
use templates::GeneralContext;
use templates::gen_captcha;
use templates::Lang;
use templates::LangHeader;
use templates::ValidLanguages;
use templates::LangChild;
use templates::tr_helper;
use templates::IPAddress;
use form::LinksForm;
use link::gen_random;

// base64 uses
use base64::{encode as base64_encode};
use base64::{encode_config as base64_encode_config};
use base64::URL_SAFE_NO_PAD;

// chrono uses
use chrono::prelude::*;
use chrono::Duration;


// constants. Please edit at your convenience
pub const INSTANCE_HOSTNAME: &str = "s.42l.fr";

pub const HOSTER_HOSTNAME: &str = "42l.fr";


// This macro from `diesel_migrations` defines an `embedded_migrations` module
// containing a function named `run`. This allows the example to be run and
// tested without any outside setup of the database.
embed_migrations!();

#[database("sqlite_database")]
pub struct DbConn(SqliteConnection);


// used to delete a link. the link name and admin key
// associated with the link are mandatory.
#[get("/<url_from>/<key>/delete")]
pub fn shortcut_admin_del(
url_from: String,
key: String,
statelang: State<Lang>,
lang_header: LangHeader,
conn: DbConn,
cookies: Cookies
) -> Template {

let i_form_result;
let mut form_is_valid = false;
let mut i_captcha_data = None;

// grabing l10n data for the page
let loc_dict = statelang.pages["home"].clone();

// getting user's language from request headers
let user_lang = lang_header.0;

// 1. check if the link exists
if let Some(db_link_info) = Link::get_link(&url_from, &conn) {
// 1.a the link exists.
// 2. check if the provided key is correct
if base64_encode_config(&db_link_info.key, URL_SAFE_NO_PAD) == key {
// 2.a the key is correct
// 3. try to delete the link as requested
// unwrap: the id is None only when the link isn't in DB yet, which isn't the case.
if Link::delete_by_id(db_link_info.id.unwrap(), &conn) {
// 3.a delete succeded
// display a nice success message
form_is_valid = true;
i_form_result = Some(loc_dict.lang["link_delete_success"][&user_lang].clone());
}
else {
// 3.b delete failed. that's a problem.
eprintln!("WARN: Delete failed for link {} (DB error)", db_link_info.url_from);
i_form_result = Some(loc_dict.lang["error_link_delete_db_fail"][&user_lang].clone());
}
}
else {
// 2.b the key is invalid
i_form_result = Some(loc_dict.lang["error_invalid_key"][&user_lang].clone());
}
}
else {
// 1.b the link doesn't exist
i_form_result = Some(loc_dict.lang["error_link_not_found"][&user_lang].clone());
}

// generating new captcha
// stored as a tuple (captcha_answer, captcha_png)
if let Some(captcha_data) = gen_captcha() {
// sets the captcha cookie
cookie_captcha_set(&captcha_data.0, cookies);
// converts the data to b64
i_captcha_data = Some(base64_encode(&captcha_data.1));
}

Template::render(loc_dict.clone().template, &gen_default_context(
loc_dict, user_lang, i_captcha_data, i_form_result, form_is_valid, None))

}

// the "link admin panel" route. clients are redirected to this route
// when they create a link, with created=true.
// Allows people to delete their links and check the click count.
#[get("/<url_from>/<key>?<created>")]
pub fn shortcut_admin(
url_from: String,
key: String,
created: bool,
statelang: State<Lang>,
lang_header: LangHeader,
conn: DbConn,
cookies: Cookies
) -> Template {

let mut form_is_valid = false;
let mut i_form_result = None;
let mut i_captcha_data = None;
let mut link_info = None;

// grabing l10n data for the page
let loc_dict = statelang.pages["home"].clone();

// getting user's language from request headers
let user_lang = lang_header.0;

// 1. check if the link exists
if let Some(db_link_info) = Link::get_link(&url_from, &conn) {
// 1.a the link exists.
// 2. check if the provided key is correct
if base64_encode_config(&db_link_info.key, URL_SAFE_NO_PAD) == key {
// 2.a the key is correct
// 3. display success message if the link has just been created
if created == true {
i_form_result = Some(loc_dict.lang["form_success"][&user_lang].clone());
form_is_valid = true;
}
link_info = Some(db_link_info);
}
else {
// 2.b the key is invalid
i_form_result = Some(loc_dict.lang["error_invalid_key"][&user_lang].clone());
}
}
else {
// 1.b the link doesn't exist
i_form_result = Some(loc_dict.lang["error_link_not_found"][&user_lang].clone());
}

// generating new captcha
// stored as a tuple (captcha_answer, captcha_png)
if let Some(captcha_data) = gen_captcha() {
// sets the captcha cookie
cookie_captcha_set(&captcha_data.0, cookies);
// converts the data to b64
i_captcha_data = Some(base64_encode(&captcha_data.1));
}

Template::render(loc_dict.clone().template, &gen_default_context(
loc_dict, user_lang, i_captcha_data, i_form_result, form_is_valid, link_info))

}

// route taken by people who gets redirected.
// the click count is also incremented.
#[get("/<url_from>")]
pub fn shortcut(
url_from: String,
statelang: State<Lang>,
lang_header: LangHeader,
conn: DbConn,
cookies: Cookies
) -> Result<Redirect, Template> {

// if the link exists, redirects to it.
if let Some(link) = Link::get_link(&url_from, &conn) {
if Link::increment_by_id(&link, &conn) == false {
eprintln!("WARN: Failed to increment a link. DB fail?");
}
Ok(Redirect::to(link.url_to))
}
else {
let mut i_form_result = None;
let mut i_captcha_data = None;
// grabing l10n data for the page
let loc_dict = statelang.pages["home"].clone();

// getting user's language from request headers
let user_lang = lang_header.0;

if let Some(captcha_data) = gen_captcha() {
// sets the captcha cookie
cookie_captcha_set(&captcha_data.0, cookies);
// converts the data to b64
i_captcha_data = Some(base64_encode(&captcha_data.1));
i_form_result = Some(loc_dict.lang["error_invalid_link"][&user_lang].clone());
}

Err(Template::render(loc_dict.clone().template, &gen_default_context(
loc_dict, user_lang, i_captcha_data, i_form_result, false, None)))
}
}

// route used to create a link.
#[post("/", data = "<linkform>")]
pub fn home_post(statelang: State<Lang>,
lang_header: LangHeader,
mut cookies: Cookies,
conn: DbConn,
linkform: Form<LinksForm>,
addr: IPAddress
) -> Result<Redirect, Template> {
// success: redirects to link admin page
// err: displays home with error message

// defining i_form_result's scope
let i_form_result;
let mut i_captcha_data = None;

// grabing l10n data for the page
let loc_dict = statelang.pages["home"].clone();

// getting user's language from request headers
let user_lang = lang_header.0;

// 0. check if form is valid
if let Some(linkform) = linkform.is_valid() {
if let Some(captcha_key) = cookie_captcha_get(&mut cookies) {
// 1. check session validity (30 minutes)
if captcha_key.0 < (Utc::now().naive_utc() - Duration::minutes(30)) {
// session expired
i_form_result = Some(loc_dict.lang["error_session_expired"][&user_lang].clone());
}
else {
// valid session
// 2. check for captcha validity
// we're cool. we don't check the case
// (?) might change at some point
if captcha_key.1.to_lowercase() == linkform.captcha.0.to_lowercase() {
// valid captcha
// 3. check for custom name availability
// generate random url_from if not specified
let new_url_from = match linkform.url_from.0.len() == 0 {
true => base64_encode_config(&gen_random(6), URL_SAFE_NO_PAD),
false => linkform.url_from.0,
};
// then try to get an eventual existing link from db
if let Some(_) = Link::get_link(&new_url_from, &conn) {
// error, the link already exists.
i_form_result = Some(loc_dict.lang["error_link_already_exists"][&user_lang].clone());
}
else {
// 4. the link doesn't exist, try to create it.
if let Some(newlink) = Link::insert(new_url_from.clone(), linkform.url_to.0, &conn) {
// SUCCESS ROUTE!! GOOD END
// redirects to the link admin page
return Ok(Redirect::to(uri!(
shortcut_admin:
newlink.url_from,
base64_encode_config(&newlink.key, URL_SAFE_NO_PAD),
true
)));
}
else {
// error, db fail
eprintln!("WARN: Failed to insert link {} (DB error)", new_url_from);
i_form_result = Some(loc_dict.lang["error_db_fail"][&user_lang].clone());
}
}
}
else {
// invalid captcha!!
i_form_result = Some(loc_dict.lang["error_captcha_fail"][&user_lang].clone());
println!("INFO: [{}] failed the captcha.", addr.0);
}
}
}
else {
// error : failed to parse cookie (shouldn't theorically happen)
i_form_result = Some(loc_dict.lang["error_cookie_parse_fail"][&user_lang].clone());
}

}
else {
// error: some of the fields are invalid
i_form_result = Some(loc_dict.lang["error_invalid_form"][&user_lang].clone());
println!("INFO: [{}] submitted an invalid form.", addr.0);

}

// generating new captcha
// stored as a tuple (captcha_answer, captcha_png)
if let Some(captcha_data) = gen_captcha() {
// sets the captcha cookie
cookie_captcha_set(&captcha_data.0, cookies);
// converts the data to b64
i_captcha_data = Some(base64_encode(&captcha_data.1));
}

Err(Template::render(loc_dict.clone().template, &gen_default_context(
loc_dict, user_lang, i_captcha_data, i_form_result, false, None)))
}

// main route.
#[get("/")]
pub fn home(statelang: State<Lang>,
lang_header: LangHeader,
cookies: Cookies
) -> Template {

let i_form_result = None;
let mut i_captcha_data = None;

// grabing l10n data for the page
let loc_dict = statelang.pages["home"].clone();

// getting user's language from request headers
let user_lang = lang_header.0;

// generating new captcha
// stored as a tuple (captcha_answer, captcha_png)
if let Some(captcha_data) = gen_captcha() {
// sets the captcha cookie
cookie_captcha_set(&captcha_data.0, cookies);
// converts the data to b64
i_captcha_data = Some(base64_encode(&captcha_data.1));
}

Template::render(loc_dict.clone().template, &gen_default_context(
loc_dict, user_lang, i_captcha_data, i_form_result, false, None))

}

// this function is used, so less code is copypasted.
fn gen_default_context(loc: LangChild,
lang: ValidLanguages,
i_captcha_data: Option<String>,
mut i_form_result: Option<String>,
mut form_is_valid: bool,
link_info: Option<Link>) -> GeneralContext {

// if the captcha gen has gone wrong, overriding the error message here
if i_captcha_data == None {
i_form_result = Some(loc.lang["captcha_crash"][&lang].clone());
form_is_valid = false;
}

GeneralContext {
loc: loc,
l: lang,
captcha: i_captcha_data,
parent: "layout",
form_result: i_form_result,
form_is_valid: form_is_valid,
linkinfo: match link_info {
Some(v) => Some(LinkInfo::create_from(v)),
None => None,
},
hoster: HOSTER_HOSTNAME,
}
}

fn rocket() -> (Rocket, Option<DbConn>) {
let rocket = rocket::ignite()
.manage(Lang::init())
.attach(DbConn::fairing())
.attach(AdHoc::on_attach("Database Migrations", |rocket| {
let conn = DbConn::get_one(&rocket).expect("database connection");
match embedded_migrations::run(&*conn) {
Ok(()) => Ok(rocket),
Err(e) => {
eprintln!("Failed to run database migrations: {:?}", e);
Err(rocket)
},
}
}))
.mount("/", routes![
home, home_post, shortcut, shortcut_admin, shortcut_admin_del
])
.mount("/assets", StaticFiles::from("assets/").rank(-10))
.attach(Template::custom(|engines| {
engines.handlebars.register_helper("tr", Box::new(tr_helper));
}));

let conn = match cfg!(test) {
true => DbConn::get_one(&rocket),
false => None,
};

(rocket, conn)
}

fn main() {
rocket().0.launch();
}

+ 188
- 0
src/templates.rs View File

@@ -0,0 +1,188 @@
// std uses
use std::collections::HashMap;
use std::fs::File;
use std::io::Read;
use std::fmt;
use std::net::{IpAddr, Ipv4Addr};

// Rocket uses
use rocket_contrib::templates::handlebars::{Helper, Handlebars, Context, RenderContext, HelperResult, Output};
use rocket::Outcome::*;
use rocket::Request;
use rocket::request::{self, FromRequest};

// captcha uses
use captcha::Captcha;
use captcha::filters::{Noise, Wave, Grid};

// rand uses
use rand::Rng;

// local uses
use crate::link::LinkInfo;

pub const LANG_FILE: &str = "./lang.json";
pub const DEFAULT_LANGUAGE: ValidLanguages = ValidLanguages::Fr;

// DEFINE VALID LANGUAGES HERE
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
pub enum ValidLanguages {
En, Fr,
}

// The lang codes MUST correspond to the
// Accept-Language header format.
impl ValidLanguages {
pub fn from_str(s: &str) -> ValidLanguages {
match s.to_lowercase().as_str() {
"en" => ValidLanguages::En,
"fr" => ValidLanguages::Fr,
_ => DEFAULT_LANGUAGE,
}
}
pub fn get_list() -> Vec<&'static str> {
// ALSO DEFINE VALID LANGUAGES HERE AND JUST ABOVE
vec!["En", "Fr"]
}
}

impl fmt::Display for ValidLanguages {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:?}", self)
}
}

pub struct LangHeader(pub ValidLanguages);

impl<'a, 'r> FromRequest<'a, 'r> for LangHeader {
type Error = ();
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, ()> {
match request.clone().headers().get_one("Accept-Language") {
Some(s) => {
let mut s_s = String::from(s);
s_s.to_lowercase();
s_s.truncate(2);
match s_s.len() < 2 {
true => Success(LangHeader(DEFAULT_LANGUAGE)),
false => Success(LangHeader(ValidLanguages::from_str(s_s.as_str()))),
}
}
None => Success(LangHeader(DEFAULT_LANGUAGE))
}
}
}

#[derive(Debug)]
pub struct IPAddress(pub IpAddr);

impl<'a, 'r> FromRequest<'a, 'r> for IPAddress {
type Error = ();
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, ()> {
// if we can't read the client IP address, defaults to unspecified
// to avoid panic. Also writes an error in stdout.
// Let's not forget to consider that "feature" in case IP-based
// features gets implemented someday.
let ipaddr = request.client_ip()
.unwrap_or(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)));
if ipaddr.is_unspecified() {
eprintln!("WARN: Failed to read client IP address");
}
Success(IPAddress(ipaddr))
}
}

// Generic Template Context
// That's not like there are so many pages anyway
#[derive(Serialize)]
pub struct GeneralContext {
// l10n data for the page
pub loc: LangChild,
// l is the user's language
pub l: ValidLanguages,
// the captcha.png as base64
pub captcha: Option<String>,
// the parent template - if needed.
pub parent: &'static str,
// if form_result is set, a notification will be displayed.
// the notification color will be green or red depending on form_is_valid.
pub form_result: Option<String>,
pub form_is_valid: bool,
// for the link admin page (access with key) + link creation
pub linkinfo: Option<LinkInfo>,
// service hoster address, constant defined in main.
pub hoster: &'static str,
}

#[derive(Serialize, Deserialize)]
pub struct Lang {
pub pages: HashMap<String, LangChild>,
}

#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct LangChild {
pub template: String,
pub lang: HashMap<String, HashMap<ValidLanguages, String>>
}

impl Lang {
pub fn init() -> Self {
let mut file = File::open(LANG_FILE)
.expect("Lang.init(): Can't open lang file!!");
let mut data = String::new();
file.read_to_string(&mut data)
.expect("Lang.init(): Can't read lang file!!");
let json: Lang = serde_json::from_str(&data)
.expect("Lang.init(): lang file JSON parse fail!!");
json
}
}

pub fn tr_helper(
h: &Helper,
_: &Handlebars,
_: &Context,
_: &mut RenderContext,
out: &mut dyn Output,
) -> HelperResult {

if let Some(loc_key) = h.param(0) {
if let Some(lang) = h.param(1) {
let selected_lang = ValidLanguages::from_str(
lang.value().as_str()
.unwrap_or(&format!("{}", DEFAULT_LANGUAGE)));

// in case we fail to extract data for default language, we display
// "TR_Error" instead of panicking.
let error = json!("TR_Error");
let loc_value = loc_key.value().get(format!("{}", selected_lang))
.unwrap_or(loc_key.value().get(format!("{}", DEFAULT_LANGUAGE))
.unwrap_or(&error));

if loc_value == "TR_Error" {
eprintln!("tr_helper: TR_Error at {:?} for language {}",
loc_key, selected_lang);
}

out.write(loc_value.as_str()
.expect("tr_helper: failed to convert loc_value to &str (text)"))?;
}
}
Ok(())
}


pub fn gen_captcha() -> Option<(String, Vec<u8>)> {

let mut rng = rand::thread_rng();

Captcha::new()
.add_chars(6)
.apply_filter(Noise::new(0.1))
.apply_filter(Wave::new(rng.gen_range(1, 4) as f64, rng.gen_range(6, 13) as f64).horizontal())
.apply_filter(Wave::new(rng.gen_range(1, 4) as f64, rng.gen_range(6, 13) as f64).vertical())
.apply_filter(Grid::new(rng.gen_range(15, 25), rng.gen_range(15, 25)))
.apply_filter(Wave::new(rng.gen_range(1, 4) as f64, rng.gen_range(5, 9) as f64).horizontal())
.apply_filter(Wave::new(rng.gen_range(1, 4) as f64, rng.gen_range(5, 9) as f64).vertical())
.view(250, 100)
.as_tuple()
}

+ 7
- 0
templates/head.hbs View File

@@ -0,0 +1,7 @@
<head>
<meta charset="utf-8" />
<title>42l - {{tr loc.lang.title l}}</title>
<meta name="description" content="{{tr loc.lang.desc l}}" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/assets/base.css" />
</head>

+ 90
- 0
templates/home.hbs View File

@@ -0,0 +1,90 @@
{{#*inline "page"}}
<div class="s-logo">
<div class="s-space"></div>
<img src="/assets/logo.svg" alt="{{tr loc.lang.logo_alt l}}"/>
<div class="s-space"></div>
</div>
<div class="s-backbutton">
<a href="https://{{hoster}}">
<img src="/assets/hoster-logo.png" alt="{{tr loc.lang.hoster_logo_alt l}}"/>
</a>
</div>
<section class="s-section">
{{#if form_result}}
<div class="s-field">
{{#if form_is_valid}}
<div class="s-notification s-success">
{{form_result}}
</div>
{{else}}
<div class="s-notification s-error">
{{form_result}}
</div>
{{/if}}
</div>
{{/if}}
{{#if linkinfo}}
<div class="s-field">
<div class="s-notification s-link">
<div class="s-field s-link-group">
<label for="new_link">{{tr loc.lang.notif_link_label_shortcut l}}</label>
<input type="text" id="new_link" name="new_link" readonly value="{{linkinfo.url_from}}">
</div>
<br />
<div class="s-field s-link-group">
<label for="old_link">{{tr loc.lang.notif_link_label_original l}}</label>
<input type="text" id="old_link" name="old_link" readonly value="{{linkinfo.url_to}}">
</div>
<br />
<div class="s-field s-link-group">
<label for="new_link">{{tr loc.lang.notif_link_label_admin l}}</label>
<input type="text" id="new_link" name="new_link" readonly value="{{linkinfo.adminlink}}">
</div>
<small>{{tr loc.lang.notif_link_admin_desc l}}</small>
<br />
<p>{{tr loc.lang.notif_link_clickcount l}}{{linkinfo.clicks}}</p>
<a class="s-button s-danger" href="{{linkinfo.adminlink}}/delete">{{tr loc.lang.notif_link_delete l}}</a>


</div>
</div>
{{/if}}
<form action="/" method="post">
<div class="s-field">
<label for="url_to">{{tr loc.lang.url_to_label l}}</label>
<input type="url" id="url_to" name="url_to" required placeholder="{{tr loc.lang.url_to_placeholder l}}" autofocus>
</div>
<br />
<br />
<div class="s-field">
<label for="url_from">{{tr loc.lang.url_from_label l}}</label>
<input type="text" id="url_from" name="url_from" maxlength="80" placeholder="{{tr loc.lang.url_from_placeholder l}}">
</div>
<br />
<br />
<div class="s-field s-captcha-section">
<div class="s-captcha-img">
<img src="data:image/png;base64, {{captcha}}" alt="{{tr loc.lang.captcha_alt l}}" />
</div>
<br />
<div class="s-field s-captcha-field">
<label for="captcha">{{tr loc.lang.captcha_label l}}</label>
<input type="text" id="captcha" name="captcha" maxlength="8" placeholder="{{tr loc.lang.captcha_placeholder l}}" autocomplete="off" required>
</div>
</div>
<br />
<br />
<div class="s-field">
<button class="s-button s-primary" type="submit">
{{tr loc.lang.button_submit_text l}}
</button>
</div>
<br />
<br />
<div class="s-field s-footer">
<a href="https://git.42l.fr/42l/rs-short">{{tr loc.lang.footer_source_code l}}</a>
</div>
</form>
</section>
{{/inline}}
{{~> (parent)~}}

+ 11
- 0
templates/layout.hbs View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="{{l}}">
{{> head}}
<body>
{{> nav}}
<div class="to-page-size">
{{~> page}}
</div>
{{> footer}}
</body>
</html>

Loading…
Cancel
Save