Repos / pytaku / 90dc7eec3f
commit 90dc7eec3f408e1070d0603089aca48d830b2169
Author: Bùi Thành Nhân <hi@imnhan.com>
Date:   Tue Aug 18 22:15:53 2020 +0700

    revamp login
    
    - Use standard issue bearer token
    - Implement require_token decorator & use on new API views
    - Client-side Auth state management
    - Use layout pattern on client side to use Navbar in one place

diff --git a/src/pytaku/database/migrations/latest_schema.sql b/src/pytaku/database/migrations/latest_schema.sql
index 7883803..c9bf35d 100644
--- a/src/pytaku/database/migrations/latest_schema.sql
+++ b/src/pytaku/database/migrations/latest_schema.sql
@@ -63,11 +63,10 @@ CREATE TABLE IF NOT EXISTS "read" (
 );
 CREATE TABLE token (
     user_id integer not null,
-    token text not null,
+    token text unique not null,
     created_at text not null default (datetime('now')),
     last_accessed_at text not null default (datetime('now')),
     lifespan text not null, -- '+1 day', '+365 days', etc.
 
-    foreign key (user_id) references user (id),
-    unique(user_id, token)
+    foreign key (user_id) references user (id)
 );
diff --git a/src/pytaku/database/migrations/m0004.sql b/src/pytaku/database/migrations/m0004.sql
index a1641ea..ca0b019 100644
--- a/src/pytaku/database/migrations/m0004.sql
+++ b/src/pytaku/database/migrations/m0004.sql
@@ -2,13 +2,12 @@ begin transaction;
 
 create table token (
     user_id integer not null,
-    token text not null,
+    token text unique not null,
     created_at text not null default (datetime('now')),
     last_accessed_at text not null default (datetime('now')),
     lifespan text not null, -- '+1 day', '+365 days', etc.
 
-    foreign key (user_id) references user (id),
-    unique(user_id, token)
+    foreign key (user_id) references user (id)
 );
 
 commit;
diff --git a/src/pytaku/decorators.py b/src/pytaku/decorators.py
index 6cc1422..8bcaa84 100644
--- a/src/pytaku/decorators.py
+++ b/src/pytaku/decorators.py
@@ -1,10 +1,8 @@
 from functools import wraps
 
 from flask import jsonify, redirect, request, session, url_for
-from itsdangerous import SignatureExpired, URLSafeTimedSerializer
 
-from .conf import config
-from .persistence import read, unread
+from .persistence import read, unread, verify_token
 
 
 def require_login(f):
@@ -20,18 +18,22 @@ def decorated_function(*args, **kwargs):
 def require_token(f):
     @wraps(f)
     def decorated_function(*args, **kwargs):
-        token = request.headers.get("Pytaku-Token")
-        if not token:
-            return jsonify({"message": "Please provide Pytaku-Token header."}), 401
-        s = URLSafeTimedSerializer(config.FLASK_SECRET_KEY, salt="access_token")
-        try:
-            user_id = s.loads(token)
-        except SignatureExpired:
-            return jsonify({"message": "Token expired."}), 401
-        except Exception:
-            return jsonify({"message": "Malformed token."}), 401
-
-        return f(*args, user_id=user_id, **kwargs)
+        header = request.headers.get("Authorization")
+        if not header or not header.startswith("Bearer "):
+            return (
+                jsonify({"message": "Missing `Authorization: Bearer <token>` header."}),
+                401,
+            )
+
+        token = header[len("Bearer ") :]
+        user_id = verify_token(token)
+        if user_id is None:
+            return jsonify({"message": "Invalid token."}), 401
+
+        request.token = token
+        request.user_id = user_id
+
+        return f(*args, **kwargs)
 
     return decorated_function
 
diff --git a/src/pytaku/main.py b/src/pytaku/main.py
index 3bb6018..b73dc0e 100644
--- a/src/pytaku/main.py
+++ b/src/pytaku/main.py
@@ -19,7 +19,7 @@
 )
 
 from .conf import config
-from .decorators import require_login, toggle_has_read
+from .decorators import require_login, require_token, toggle_has_read
 from .persistence import (
     create_token,
     delete_token,
@@ -34,7 +34,6 @@
     save_chapter,
     save_title,
     unfollow,
-    verify_token,
     verify_username_password,
 )
 from .source_sites import (
@@ -418,22 +417,22 @@ def api_login():
     return jsonify({"user_id": user_id, "token": token}), 200
 
 
-@app.route("/api/verify-token", methods=["POST"])
+@app.route("/api/verify-token", methods=["GET"])
+@require_token
 def api_verify_token():
-    user_id = request.json["user_id"]
-    token = request.json["token"]
-    is_valid = verify_token(user_id, token)
-    if not is_valid:
-        return jsonify({"message": "Invalid token."}), 401
-    return {"username": get_username(user_id)}, 200
+    return {"user_id": request.user_id, "username": get_username(request.user_id)}, 200
 
 
 @app.route("/api/logout", methods=["POST"])
+@require_token
 def api_logout():
-    # TODO: should probably be using auth http header like other APIs
-    user_id = request.json["user_id"]
-    token = request.json["token"]
-    num_deleted = delete_token(user_id, token)
+    num_deleted = delete_token(request.token)
     if num_deleted != 1:
         return jsonify({"message": "Invalid token."}), 401
     return "{}", 200
+
+
+@app.route("/api/follows", methods=["GET"])
+@require_token
+def api_follows():
+    return jsonify({"message": "TODO"})
diff --git a/src/pytaku/persistence.py b/src/pytaku/persistence.py
index 57a581d..7cb0a81 100644
--- a/src/pytaku/persistence.py
+++ b/src/pytaku/persistence.py
@@ -350,7 +350,7 @@ def create_token(user_id, remember=False):
     return token
 
 
-def verify_token(user_id, token):
+def verify_token(token):
     """
     Checks if there's a matching token that hasn't exceeded its lifespan.
     If there's a match, refreshes its last_accessed_at value, effectively expanding
@@ -358,22 +358,19 @@ def verify_token(user_id, token):
     """
     result = run_sql(
         """
-        SELECT 1 FROM token
-        WHERE user_id=? AND token=?
+        SELECT user_id FROM token
+        WHERE token=?
           AND datetime(last_accessed_at, lifespan) > datetime('now');
         """,
-        (user_id, token),
+        (token,),
     )
-    is_success = len(result) == 1
-    if is_success:
+    user_id = result[0] if len(result) == 1 else None
+    if user_id:
         run_sql(
-            """
-            UPDATE token SET last_accessed_at = datetime('now')
-            WHERE user_id=? AND token=?;
-            """,
-            (user_id, token),
+            "UPDATE token SET last_accessed_at = datetime('now') WHERE token=?;",
+            (token,),
         )
-    return is_success
+    return user_id
 
 
 def get_username(user_id):
@@ -382,11 +379,9 @@ def get_username(user_id):
     return result[0]
 
 
-def delete_token(user_id, token):
+def delete_token(token):
     num_deleted = run_sql(
-        "DELETE FROM token WHERE user_id=? AND token=?",
-        (user_id, token),
-        return_num_affected=True,
+        "DELETE FROM token WHERE token=?;", (token,), return_num_affected=True,
     )
     return num_deleted
 
diff --git a/src/pytaku/static/js/common-components.js b/src/pytaku/static/js/layout.js
similarity index 64%
rename from src/pytaku/static/js/common-components.js
rename to src/pytaku/static/js/layout.js
index 22852ee..7807b0f 100644
--- a/src/pytaku/static/js/common-components.js
+++ b/src/pytaku/static/js/layout.js
@@ -16,7 +16,13 @@ function Navbar(initialVNode) {
               onclick: (ev) => {
                 isLoggingOut = true;
                 m.redraw();
-                Auth.logout();
+                Auth.logout()
+                  .then(() => {
+                    m.route.set("/");
+                  })
+                  .finally(() => {
+                    isLoggingOut = false;
+                  });
               },
               disabled: isLoggingOut ? "disabled" : null,
             },
@@ -34,12 +40,16 @@ function Navbar(initialVNode) {
       }
 
       return m("nav", [
-        m(m.route.Link, { class: "nav--logo", href: "/" }, [
-          m("img.nav--logo--img", {
-            src: "/static/pytaku.svg",
-            alt: "home",
-          }),
-        ]),
+        m(
+          m.route.Link,
+          { class: "nav--logo", href: Auth.isLoggedIn() ? "/f" : "/" },
+          [
+            m("img.nav--logo--img", {
+              src: "/static/pytaku.svg",
+              alt: "home",
+            }),
+          ]
+        ),
         m("form.nav--search-form", [
           m("input", { placeholder: "search title name" }),
           m("button", { type: "submit" }, [m("i.icon.icon-search")]),
@@ -49,4 +59,11 @@ function Navbar(initialVNode) {
     },
   };
 }
-export { Navbar };
+
+const Layout = {
+  view: (vnode) => {
+    return m("div.main", [m(Navbar), vnode.children]);
+  },
+};
+
+export default Layout;
diff --git a/src/pytaku/static/js/main.js b/src/pytaku/static/js/main.js
index cf63ec1..2dde99a 100644
--- a/src/pytaku/static/js/main.js
+++ b/src/pytaku/static/js/main.js
@@ -1,11 +1,43 @@
 import { Auth } from "./models.js";
+import Layout from "./layout.js";
 import Authentication from "./routes/authentication.js";
 import Home from "./routes/home.js";
+import Follows from "./routes/follows.js";
 
-const root = document.getElementById("spa-root");
-m.route.prefix = "";
-m.route(root, "/h", {
-  "/h": Home,
-  "/a": Authentication,
+Auth.init().then(() => {
+  const root = document.getElementById("spa-root");
+  m.route.prefix = "";
+  m.route(root, "/", {
+    "/": {
+      onmatch: () => {
+        if (Auth.isLoggedIn()) {
+          m.route.set("/f", null, { replace: true });
+        } else {
+          return Home;
+        }
+      },
+      render: () => m(Layout, m(Home)),
+    },
+    "/a": {
+      onmatch: () => {
+        if (Auth.isLoggedIn()) {
+          m.route.set("/f", null, { replace: true });
+        } else {
+          return Authentication;
+        }
+      },
+      render: () => m(Layout, m(Authentication)),
+    },
+    "/f": {
+      onmatch: () => {
+        if (Auth.isLoggedIn()) {
+          return Follows;
+        } else {
+          //m.route.set("/a", null, { replace: true });
+          return m("h1", "waiting");
+        }
+      },
+      render: () => m(Layout, m(Follows)),
+    },
+  });
 });
-Auth.init();
diff --git a/src/pytaku/static/js/models.js b/src/pytaku/static/js/models.js
index ecaee29..e1dc2b4 100644
--- a/src/pytaku/static/js/models.js
+++ b/src/pytaku/static/js/models.js
@@ -1,30 +1,34 @@
 const Auth = {
   username: sessionStorage.getItem("username"),
+  userId: sessionStorage.getItem("userId"),
   token: localStorage.getItem("token"),
-  userId: localStorage.getItem("user_id"),
-  isLoggedIn: () => Auth.username !== null,
+  isLoggedIn: () => Auth.username !== null && Auth.userId !== null,
   init: () => {
     // Already logged in, probably from another tab:
-    if (Auth.username !== null) {
-      return;
+    if (Auth.isLoggedIn()) {
+      console.log("Already logged in");
+      return Promise.resolve();
     }
 
     // No previous login session:
-    if (Auth.token === null || Auth.userId === null) {
-      return;
+    if (Auth.token === null) {
+      console.log("No saved token found");
+      return Promise.resolve();
     }
 
-    // Verify token & user_id saved from previous login session:
+    // Verify token saved from previous login session:
     return m
       .request({
-        method: "POST",
+        method: "GET",
         url: "/api/verify-token",
-        body: { token: Auth.token, user_id: Auth.userId },
+        headers: { Authorization: `Bearer ${Auth.token}` },
       })
       .then((result) => {
-        // Success! Set username for this session now
+        // Success! Set user info for this session now
         sessionStorage.setItem("username", result.username);
+        sessionStorage.setItem("userId", result.user_id);
         Auth.username = result.username;
+        Auth.userId = result.user_id;
       })
       .catch((err) => {
         // If server responded with 401 Unauthorized, clear any local trace of
@@ -32,12 +36,25 @@ const Auth = {
         if (err.code == 401) {
           Auth.clearCredentials();
         }
-      });
+      })
+      .finally(m.redraw);
   },
 
-  saveLoginResults: ({ userId, username, token }) => {
+  saveLoginResults: ({ userId, username, token, remember }) => {
+    // FIXME: currently, even when remember=false we're still storing the token
+    // in localStorage, simply because sessionStorage isn't shared across tabs.
+    // Unfortunately this means when user logs in without checking "remember
+    // me", the token will still linger in localstorage after browser is
+    // closed, and if an adversary reopens browser within the token's
+    // server-enforced lifespan (1 day), then user is pwned.
+    //
+    // Either that or we stick to sessionStorage and forget multitab support.
+    // _OR_ we do a convoluted song and dance with storage events:
+    // > https://stackoverflow.com/a/32766809
+    //
+    // 0 days since web APIs last made me sad.
+    sessionStorage.setItem("userId", userId);
     sessionStorage.setItem("username", username);
-    localStorage.setItem("user_id", userId);
     localStorage.setItem("token", token);
     Auth.userId = userId;
     Auth.username = username;
@@ -45,12 +62,7 @@ const Auth = {
   },
 
   logout: () => {
-    return m
-      .request({
-        method: "POST",
-        url: "/api/logout",
-        body: { token: Auth.token, user_id: Auth.userId },
-      })
+    return Auth.request({ method: "POST", url: "/api/logout" })
       .then(Auth.clearCredentials)
       .catch((err) => {
         if (err.code == 401) {
@@ -68,6 +80,13 @@ const Auth = {
     localStorage.clear();
     sessionStorage.clear();
   },
+
+  request: (options) => {
+    if (Auth.isLoggedIn()) {
+      options.headers = { Authorization: `Bearer ${Auth.token}` };
+    }
+    return m.request(options);
+  },
 };
 
 export { Auth };
diff --git a/src/pytaku/static/js/routes/authentication.js b/src/pytaku/static/js/routes/authentication.js
index 1e6743a..bb37d16 100644
--- a/src/pytaku/static/js/routes/authentication.js
+++ b/src/pytaku/static/js/routes/authentication.js
@@ -1,10 +1,9 @@
-import { Navbar } from "../common-components.js";
 import { Auth } from "../models.js";
 
 function Authentication(initialVNode) {
   let loginUsername;
   let loginPassword;
-  let rememberMe;
+  let rememberMe = false;
   let loginErrorMessage;
   let registerUsername;
   let registerPassword;
@@ -20,167 +19,170 @@ function Authentication(initialVNode) {
       document.title = "Authentication - Pytaku";
     },
     view: (vnode) => {
-      return m("div.main", [
-        m(Navbar),
-        m("div.content.auth", [
-          m(
-            "form.auth--form",
-            {
-              onsubmit: (e) => {
-                e.preventDefault();
-                loginErrorMessage = "";
-                loggingIn = true;
-                m.redraw();
+      return m("div.content.auth", [
+        m(
+          "form.auth--form",
+          {
+            onsubmit: (e) => {
+              e.preventDefault();
+              loginErrorMessage = "";
+              loggingIn = true;
+              m.redraw();
 
-                m.request({
-                  method: "POST",
-                  url: "/api/login",
-                  body: {
-                    username: loginUsername,
-                    password: loginPassword,
+              m.request({
+                method: "POST",
+                url: "/api/login",
+                body: {
+                  username: loginUsername,
+                  password: loginPassword,
+                  remember: rememberMe,
+                },
+              })
+                .then((result) => {
+                  let userId = result.user_id;
+                  let token = result.token;
+                  let username = loginUsername;
+                  Auth.saveLoginResults({
+                    userId,
+                    username,
+                    token,
                     remember: rememberMe,
-                  },
-                })
-                  .then((result) => {
-                    loggingIn = false;
-                    let userId = result.user_id;
-                    let token = result.token;
-                    let username = loginUsername;
-                    Auth.saveLoginResults({ userId, username, token });
-                    m.route.set("/");
-                  })
-                  .catch((e) => {
-                    console.log(e);
-                    loggingIn = false;
-                    loginErrorMessage = e.response.message;
                   });
-              },
+                  m.route.set("/f");
+                })
+                .catch((e) => {
+                  loginErrorMessage = e.response.message;
+                })
+                .finally(() => {
+                  loggingIn = false;
+                });
             },
-            [
-              m("h1", "Login"),
-              m("input[placeholder=username][name=username][required]", {
-                value: loginUsername,
+          },
+          [
+            m("h1", "Login"),
+            m("input[placeholder=username][name=username][required]", {
+              value: loginUsername,
+              oninput: (e) => {
+                loginUsername = e.target.value;
+              },
+            }),
+            m(
+              "input[placeholder=password][name=password][type=password][required]",
+              {
+                value: loginPassword,
                 oninput: (e) => {
-                  loginUsername = e.target.value;
+                  loginPassword = e.target.value;
                 },
-              }),
-              m(
-                "input[placeholder=password][name=password][type=password][required]",
-                {
-                  value: loginPassword,
-                  oninput: (e) => {
-                    loginPassword = e.target.value;
-                  },
-                }
-              ),
-              m("label[for=auth--remember].auth--checkbox-label", [
-                m("input[type=checkbox][name=remember][id=auth--remember]", {
-                  checked: rememberMe,
-                  onchange: (e) => {
-                    rememberMe = e.target.checked;
-                  },
-                }),
-                " Remember me",
-              ]),
-              m(
-                "button[type=submit]",
-                {
-                  disabled: loggingIn ? "disabled" : null,
+              }
+            ),
+            m("label[for=auth--remember].auth--checkbox-label", [
+              m("input[type=checkbox][name=remember][id=auth--remember]", {
+                checked: rememberMe,
+                onchange: (e) => {
+                  rememberMe = e.target.checked;
                 },
-                [
-                  m("i.icon.icon-log-in"),
-                  loggingIn ? " Logging in..." : " Log in",
-                ]
-              ),
-              m("p.auth--form--error-message", loginErrorMessage),
-            ]
-          ),
-          m(
-            "form.auth--form",
-            {
-              onsubmit: (e) => {
-                e.preventDefault();
-                registerMessage = "";
-                m.redraw();
+              }),
+              " Remember me",
+            ]),
+            m(
+              "button[type=submit]",
+              {
+                disabled: loggingIn ? "disabled" : null,
+              },
+              [
+                m("i.icon.icon-log-in"),
+                loggingIn ? " Logging in..." : " Log in",
+              ]
+            ),
+            m("p.auth--form--error-message", loginErrorMessage),
+          ]
+        ),
+        m(
+          "form.auth--form",
+          {
+            onsubmit: (e) => {
+              e.preventDefault();
+              registerMessage = "";
+              m.redraw();
 
-                if (registerPassword !== confirmPassword) {
-                  registerMessage = "Password confirmation didn't match!";
-                  registerSuccess = false;
-                  return;
-                }
+              if (registerPassword !== confirmPassword) {
+                registerMessage = "Password confirmation didn't match!";
+                registerSuccess = false;
+                return;
+              }
 
-                registering = true;
-                m.redraw();
-                m.request({
-                  method: "POST",
-                  url: "/api/register",
-                  body: {
-                    username: registerUsername,
-                    password: registerPassword,
-                  },
+              registering = true;
+              m.redraw();
+              m.request({
+                method: "POST",
+                url: "/api/register",
+                body: {
+                  username: registerUsername,
+                  password: registerPassword,
+                },
+              })
+                .then((result) => {
+                  registerSuccess = true;
+                  registerMessage = result.message;
+                  loginUsername = registerUsername;
+                  loginPassword = registerPassword;
                 })
-                  .then((result) => {
-                    registering = false;
-                    registerSuccess = true;
-                    registerMessage = result.message;
-                    loginUsername = registerUsername;
-                    loginPassword = registerPassword;
-                  })
-                  .catch((e) => {
-                    registering = false;
-                    registerMessage = e.response.message;
-                    registerSuccess = false;
-                  });
-              },
+                .catch((e) => {
+                  registerMessage = e.response.message;
+                  registerSuccess = false;
+                })
+                .finally(() => {
+                  registering = false;
+                });
             },
-            [
-              m("h1", "Register"),
-              m("input[placeholder=username][name=username][required]", {
-                value: registerUsername,
+          },
+          [
+            m("h1", "Register"),
+            m("input[placeholder=username][name=username][required]", {
+              value: registerUsername,
+              oninput: (e) => {
+                registerUsername = e.target.value;
+              },
+            }),
+            m(
+              "input[placeholder=password][name=password][type=password][required]",
+              {
+                value: registerPassword,
                 oninput: (e) => {
-                  registerUsername = e.target.value;
+                  registerPassword = e.target.value;
                 },
-              }),
-              m(
-                "input[placeholder=password][name=password][type=password][required]",
-                {
-                  value: registerPassword,
-                  oninput: (e) => {
-                    registerPassword = e.target.value;
-                  },
-                }
-              ),
-              m(
-                "input[placeholder=confirm password][name=confirm][type=password][required]",
-                {
-                  value: confirmPassword,
-                  oninput: (e) => {
-                    confirmPassword = e.target.value;
-                  },
-                }
-              ),
-              m(
-                "button[type=submit]",
-                {
-                  disabled: registering ? "disabled" : null,
-                },
-                [
-                  m("i.icon.icon-user-plus"),
-                  registering ? " Registering..." : " Register",
-                ]
-              ),
-              m(
-                "p",
-                {
-                  class:
-                    "auth--form--message-" +
-                    (registerSuccess ? "success" : "error"),
+              }
+            ),
+            m(
+              "input[placeholder=confirm password][name=confirm][type=password][required]",
+              {
+                value: confirmPassword,
+                oninput: (e) => {
+                  confirmPassword = e.target.value;
                 },
-                registerMessage
-              ),
-            ]
-          ),
-        ]),
+              }
+            ),
+            m(
+              "button[type=submit]",
+              {
+                disabled: registering ? "disabled" : null,
+              },
+              [
+                m("i.icon.icon-user-plus"),
+                registering ? " Registering..." : " Register",
+              ]
+            ),
+            m(
+              "p",
+              {
+                class:
+                  "auth--form--message-" +
+                  (registerSuccess ? "success" : "error"),
+              },
+              registerMessage
+            ),
+          ]
+        ),
       ]);
     },
   };
diff --git a/src/pytaku/static/js/routes/follows.js b/src/pytaku/static/js/routes/follows.js
new file mode 100644
index 0000000..cd4e18f
--- /dev/null
+++ b/src/pytaku/static/js/routes/follows.js
@@ -0,0 +1,31 @@
+import { Auth } from "../models.js";
+
+function Follows(initialVNode) {
+  let titles = [];
+  return {
+    oninit: () => {
+      Auth.request({
+        method: "GET",
+        url: "/api/follows",
+      });
+    },
+    oncreate: (vnode) => {
+      document.title = "Stuff I follow - Pytaku";
+    },
+    view: (vnode) => {
+      return m("div.content", [
+        titles.map((title) =>
+          m("div.title", [
+            m("div", [
+              m("a", { href: "TODO" }, [
+                m("img.cover", { src: title.thumbnail, alt: title.name }),
+              ]),
+            ]),
+          ])
+        ),
+      ]);
+    },
+  };
+}
+
+export default Follows;
diff --git a/src/pytaku/static/js/routes/home.js b/src/pytaku/static/js/routes/home.js
index df0ce6d..5005492 100644
--- a/src/pytaku/static/js/routes/home.js
+++ b/src/pytaku/static/js/routes/home.js
@@ -1,16 +1,13 @@
-import { Navbar } from "../common-components.js";
+import { Auth } from "../models.js";
 
 const Home = {
   oncreate: (vnode) => {
     document.title = "Pytaku";
   },
   view: (vnode) => {
-    return m("div.main", [
-      m(Navbar),
-      m("div.content", [
-        m("p", "Try searching for some manga title using the box above."),
-        m("p", "Logging in allows you to follow manga titles."),
-      ]),
+    return m("div.content", [
+      m("p", "Try searching for some manga title using the box above."),
+      m("p", "Logging in allows you to follow manga titles."),
     ]);
   },
 };
diff --git a/src/pytaku/templates/spa.html b/src/pytaku/templates/spa.html
index ce36cfa..dabbf90 100644
--- a/src/pytaku/templates/spa.html
+++ b/src/pytaku/templates/spa.html
@@ -7,7 +7,7 @@
     <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
     <link rel="alternate icon" type="image/png" href="/static/favicon.png">
 
-    <title>{% block title %} {% endblock %} - Pytaku</title>
+    <title>{% block title %}Pytaku{% endblock %}</title>
 
     <link rel="stylesheet" href="{{ url_for('static', filename='minireset.css') }}">
     <link rel="stylesheet" href="{{ url_for('static', filename='feathericons/iconfont.css') }}">