Added Authentication
authorAdam Dullage <redacted>
Thu, 19 Aug 2021 07:26:15 +0000 (08:26 +0100)
committerAdam Dullage <redacted>
Thu, 19 Aug 2021 07:26:15 +0000 (08:26 +0100)
Pipfile
Pipfile.lock
README.md
docker-compose.yaml
flatnotes/auth.py [new file with mode: 0644]
flatnotes/main.py
flatnotes/models.py [new file with mode: 0644]
flatnotes/src/api.js [new file with mode: 0644]
flatnotes/src/components/App.js
flatnotes/src/components/App.vue
flatnotes/src/eventBus.js [new file with mode: 0644]

diff --git a/Pipfile b/Pipfile
index 38cd0f6557c7ebc360cbd3daabe2e941b1495104..c6c963927e3b04e5b6d2dd25cefcec9e3cf7aec1 100644 (file)
--- a/Pipfile
+++ b/Pipfile
@@ -14,6 +14,7 @@ whoosh = "*"
 fastapi = "*"
 uvicorn = {extras = ["standard"], version = "*"}
 aiofiles = "*"
+python-jose = {extras = ["cryptography"], version = "*"}
 
 [requires]
 python_version = "3.8"
index 690fdf2cab0c986d7ee1661520ec5ee43e4fd1ac..d7d12200198be65a11e820416fb81248366bb723 100644 (file)
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "7bdbb656d72c9dd9bed01397a477e2f03880651d32fcbcae6aa0b5992f6c9da3"
+            "sha256": "f03f2187cd2a303a19231321028d98b5a755f2a20ff2601ce7b4739cfe9d5ae4"
         },
         "pipfile-spec": 6,
         "requires": {
             "markers": "python_version >= '3.6'",
             "version": "==3.4.1"
         },
+        "cffi": {
+            "hashes": [
+                "sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d",
+                "sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771",
+                "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872",
+                "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c",
+                "sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc",
+                "sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762",
+                "sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202",
+                "sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5",
+                "sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548",
+                "sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a",
+                "sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f",
+                "sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20",
+                "sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218",
+                "sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c",
+                "sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e",
+                "sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56",
+                "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224",
+                "sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a",
+                "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2",
+                "sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a",
+                "sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819",
+                "sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346",
+                "sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b",
+                "sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e",
+                "sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534",
+                "sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb",
+                "sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0",
+                "sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156",
+                "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd",
+                "sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87",
+                "sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc",
+                "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195",
+                "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33",
+                "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f",
+                "sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d",
+                "sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd",
+                "sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728",
+                "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7",
+                "sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca",
+                "sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99",
+                "sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf",
+                "sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e",
+                "sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c",
+                "sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5",
+                "sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69"
+            ],
+            "version": "==1.14.6"
+        },
         "click": {
             "hashes": [
                 "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a",
             ],
             "version": "==0.4.4"
         },
+        "cryptography": {
+            "hashes": [
+                "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d",
+                "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959",
+                "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6",
+                "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873",
+                "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2",
+                "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713",
+                "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1",
+                "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177",
+                "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250",
+                "sha256:b01fd6f2737816cb1e08ed4807ae194404790eac7ad030b34f2ce72b332f5586",
+                "sha256:bf40af59ca2465b24e54f671b2de2c59257ddc4f7e5706dbd6930e26823668d3",
+                "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca",
+                "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d",
+                "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9"
+            ],
+            "version": "==3.4.7"
+        },
+        "ecdsa": {
+            "hashes": [
+                "sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676",
+                "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"
+            ],
+            "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==0.17.0"
+        },
         "fastapi": {
             "hashes": [
                 "sha256:c9256a89b0436223b45f53fe3a39b178f3da6be5841a2c59deedff4b676d003f",
             ],
             "version": "==0.2.0"
         },
+        "pyasn1": {
+            "hashes": [
+                "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359",
+                "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576",
+                "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf",
+                "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7",
+                "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d",
+                "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00",
+                "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8",
+                "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86",
+                "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12",
+                "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776",
+                "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba",
+                "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2",
+                "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"
+            ],
+            "version": "==0.4.8"
+        },
+        "pycparser": {
+            "hashes": [
+                "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
+                "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==2.20"
+        },
         "pydantic": {
             "hashes": [
                 "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd",
             ],
             "version": "==0.19.0"
         },
+        "python-jose": {
+            "extras": [
+                "cryptography"
+            ],
+            "hashes": [
+                "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a",
+                "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"
+            ],
+            "index": "pypi",
+            "version": "==3.3.0"
+        },
         "pyyaml": {
             "hashes": [
                 "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf",
             ],
             "version": "==5.4.1"
         },
+        "rsa": {
+            "hashes": [
+                "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2",
+                "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9"
+            ],
+            "markers": "python_version >= '3.5' and python_version < '4'",
+            "version": "==4.7.2"
+        },
+        "six": {
+            "hashes": [
+                "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
+                "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==1.16.0"
+        },
         "starlette": {
             "hashes": [
                 "sha256:3c8e48e52736b3161e34c9f0e8153b4f32ec5d8995a3ee1d59410d92f75162ed",
                 "standard"
             ],
             "hashes": [
-                "sha256:2a76bb359171a504b3d1c853409af3adbfa5cef374a4a59e5881945a97a93eae",
-                "sha256:45ad7dfaaa7d55cab4cd1e85e03f27e9d60bc067ddc59db52a2b0aeca8870292"
+                "sha256:17f898c64c71a2640514d4089da2689e5db1ce5d4086c2d53699bf99513421c1",
+                "sha256:d9a3c0dd1ca86728d3e235182683b4cf94cd53a867c288eaeca80ee781b2caff"
             ],
             "index": "pypi",
-            "version": "==0.14.0"
+            "version": "==0.15.0"
         },
         "watchgod": {
             "hashes": [
         },
         "tomli": {
             "hashes": [
-                "sha256:056f0376bf5a6b182c513f9582c1e5b0487265eb6c48842b69aa9ca1cd5f640a",
-                "sha256:d60e681734099207a6add7a10326bc2ddd1fdc36c1b0f547d00ef73ac63739c2"
+                "sha256:8dd0e9524d6f386271a36b41dbf6c57d8e32fd96fd22b6584679dc569d20899f",
+                "sha256:a5b75cb6f3968abb47af1b40c1819dc519ea82bcc065776a866e8d74c5ca9442"
             ],
             "markers": "python_version >= '3.6'",
-            "version": "==1.2.0"
+            "version": "==1.2.1"
         }
     }
 }
index 6335e1ad4473b6be2dc365c6d2d345f7a5619923..435d57860fc59ee6713e2712ff0d0b0aa134c50d 100644 (file)
--- a/README.md
+++ b/README.md
@@ -29,16 +29,16 @@ This is what flatnotes aims to achieve.
 * [x] Proof of Concept - Stage 1
   * [x] Notes List
   * [x] Full Text Searching
-* [ ] Proof of Concept - Stage 2
+* [x] Proof of Concept - Stage 2
   * [x] View Note Content
   * [x] Edit Note Content
   * [x] Docker Deployment
-  * [ ] Password Authentication
+  * [x] Password Authentication
 * [ ] Proof of Concept - Stage 3
+  * [ ] Public URL Sharing
+* [ ] Proof of Concept - Stage 4
   * [ ] Image Embedding
   * [ ] Attachment Upload
-* [ ] Proof of Concept - Stage 4
-  * [ ] Public URL Sharing
 * [ ] First Release
   * [ ] Clean & Responsive UI
   * [ ] Ability to Create a Note
index 0fb62be9e4f2e0bc5120172b947297e74f38c3c5..56970151e25f0b3095ae1667225bc1747f877cfc 100644 (file)
@@ -5,9 +5,14 @@ services:
     container_name: flatnotes
     image: dullage/flatnotes:latest
     build: .
+    environment:
+      FLATNOTES_USERNAME: "user"
+      FLATNOTES_PASSWORD: "changeMe!"
+      FLATNOTES_SECRET_KEY: "aLongRandomSeriesOfCharacters"
+      # FLATNOTES_SESSION_EXPIRY_DAYS: "7"                   # Optional. Defaults to 30.
     volumes:
       - "./data:/data"
-      # - "./index:/data/.flatnotes"  # Optional
+      # - "./index:/data/.flatnotes"                         # Optional.
     ports:
       - "80:80"
     restart: unless-stopped
diff --git a/flatnotes/auth.py b/flatnotes/auth.py
new file mode 100644 (file)
index 0000000..bc01357
--- /dev/null
@@ -0,0 +1,40 @@
+import os
+from datetime import datetime, timedelta
+
+from fastapi import Depends, HTTPException
+from fastapi.security import OAuth2PasswordBearer
+from jose import JWTError, jwt
+
+FLATNOTES_USERNAME = os.environ["FLATNOTES_USERNAME"]
+FLATNOTES_PASSWORD = os.environ["FLATNOTES_PASSWORD"]
+
+JWT_SECRET_KEY = os.environ["FLATNOTES_SECRET_KEY"]
+JWT_EXPIRE_DAYS = int(os.environ.get("FLATNOTES_SESSION_EXPIRY_DAYS", 30))
+JWT_ALGORITHM = "HS256"
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token")
+
+
+def create_access_token(data: dict):
+    to_encode = data.copy()
+    expiry_datetime = datetime.utcnow() + timedelta(days=JWT_EXPIRE_DAYS)
+    to_encode.update({"exp": expiry_datetime})
+    encoded_jwt = jwt.encode(
+        to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM
+    )
+    return encoded_jwt
+
+
+async def validate_token(token: str = Depends(oauth2_scheme)):
+    try:
+        payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
+        username = payload.get("sub")
+        if username is None or username.lower() != FLATNOTES_USERNAME.lower():
+            raise ValueError
+        return FLATNOTES_USERNAME
+    except:
+        raise HTTPException(
+            status_code=401,
+            detail="Invalid authentication credentials",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
index 4ac174d4889d13cc611f60df624f4b63047de84c..1258bf776b96c458e84825c30e6723c725063e5b 100644 (file)
@@ -1,18 +1,24 @@
 import logging
 import os
-from typing import Dict, List, Optional
-
-from fastapi import FastAPI
-from fastapi.responses import HTMLResponse
-from fastapi.staticfiles import StaticFiles
+from typing import List
 
+from auth import (
+    FLATNOTES_PASSWORD,
+    FLATNOTES_USERNAME,
+    validate_token,
+    create_access_token,
+)
 from error_responses import (
     file_exists_response,
     file_not_found_response,
     filename_contains_path_response,
 )
-from flatnotes import FilenameContainsPathError, Flatnotes, Note, NoteHit
-from helpers import CamelCaseBaseModel
+from fastapi import Depends, FastAPI, HTTPException
+from fastapi.responses import HTMLResponse
+from fastapi.staticfiles import StaticFiles
+from models import LoginModel, NoteHitModel, NoteModel, NotePatchModel
+
+from flatnotes import FilenameContainsPathError, Flatnotes, Note
 
 logging.basicConfig(
     format="%(asctime)s [%(levelname)s]: %(message)s",
@@ -22,43 +28,20 @@ logger = logging.getLogger()
 logger.setLevel(os.environ.get("LOGLEVEL", "INFO").upper())
 
 app = FastAPI()
-
 flatnotes = Flatnotes(os.environ["FLATNOTES_PATH"])
 
 
-class NoteModel(CamelCaseBaseModel):
-    filename: str
-    last_modified: int
-    content: Optional[str]
-
-    @classmethod
-    def dump(cls, note: Note, include_content: bool = True) -> Dict:
-        return {
-            "filename": note.filename,
-            "lastModified": note.last_modified,
-            "content": note.content if include_content else None,
-        }
-
-
-class NotePatchModel(CamelCaseBaseModel):
-    new_filename: Optional[str]
-    new_content: Optional[str]
-
-
-class NoteHitModel(CamelCaseBaseModel):
-    filename: str
-    last_modified: int
-    title_highlights: Optional[str]
-    content_highlights: Optional[str]
-
-    @classmethod
-    def dump(self, note_hit: NoteHit) -> Dict:
-        return {
-            "filename": note_hit.filename,
-            "lastModified": note_hit.last_modified,
-            "titleHighlights": note_hit.title_highlights,
-            "contentHighlights": note_hit.content_highlights,
-        }
+@app.post("/api/token")
+async def token(data: LoginModel):
+    if (
+        data.username != FLATNOTES_USERNAME
+        or data.password != FLATNOTES_PASSWORD
+    ):
+        raise HTTPException(
+            status_code=400, detail="Incorrect username or password"
+        )
+    access_token = create_access_token(data={"sub": FLATNOTES_USERNAME})
+    return {"access_token": access_token, "token_type": "bearer"}
 
 
 @app.get("/")
@@ -69,7 +52,9 @@ async def root():
 
 
 @app.get("/api/notes", response_model=List[NoteModel])
-async def get_notes(include_content: bool = False):
+async def get_notes(
+    include_content: bool = False, _: str = Depends(validate_token)
+):
     """Get all notes."""
     return [
         NoteModel.dump(note, include_content=include_content)
@@ -78,7 +63,9 @@ async def get_notes(include_content: bool = False):
 
 
 @app.post("/api/notes", response_model=NoteModel)
-async def post_note(filename: str, content: str):
+async def post_note(
+    filename: str, content: str, _: str = Depends(validate_token)
+):
     """Create a new note."""
     try:
         note = Note(flatnotes, filename, new=True)
@@ -91,7 +78,11 @@ async def post_note(filename: str, content: str):
 
 
 @app.get("/api/notes/{filename}", response_model=NoteModel)
-async def get_note(filename: str, include_content: bool = True):
+async def get_note(
+    filename: str,
+    include_content: bool = True,
+    _: str = Depends(validate_token),
+):
     """Get a specific note."""
     try:
         note = Note(flatnotes, filename)
@@ -103,7 +94,9 @@ async def get_note(filename: str, include_content: bool = True):
 
 
 @app.patch("/api/notes/{filename}", response_model=NoteModel)
-async def patch_note(filename: str, new_data: NotePatchModel):
+async def patch_note(
+    filename: str, new_data: NotePatchModel, _: str = Depends(validate_token)
+):
     try:
         note = Note(flatnotes, filename)
         if new_data.new_filename is not None:
@@ -118,7 +111,7 @@ async def patch_note(filename: str, new_data: NotePatchModel):
 
 
 @app.delete("/api/notes/{filename}")
-async def delete_note(filename: str):
+async def delete_note(filename: str, _: str = Depends(validate_token)):
     try:
         note = Note(flatnotes, filename)
         note.delete()
@@ -129,7 +122,7 @@ async def delete_note(filename: str):
 
 
 @app.get("/api/search", response_model=List[NoteHitModel])
-async def search(term: str):
+async def search(term: str, _: str = Depends(validate_token)):
     """Perform a full text search for a note."""
     return [NoteHitModel.dump(note_hit) for note_hit in flatnotes.search(term)]
 
diff --git a/flatnotes/models.py b/flatnotes/models.py
new file mode 100644 (file)
index 0000000..e71b090
--- /dev/null
@@ -0,0 +1,45 @@
+from typing import Dict, Optional
+
+from helpers import CamelCaseBaseModel
+
+from flatnotes import Note, NoteHit
+
+
+class LoginModel(CamelCaseBaseModel):
+    username: str
+    password: str
+
+
+class NoteModel(CamelCaseBaseModel):
+    filename: str
+    last_modified: int
+    content: Optional[str]
+
+    @classmethod
+    def dump(cls, note: Note, include_content: bool = True) -> Dict:
+        return {
+            "filename": note.filename,
+            "lastModified": note.last_modified,
+            "content": note.content if include_content else None,
+        }
+
+
+class NotePatchModel(CamelCaseBaseModel):
+    new_filename: Optional[str]
+    new_content: Optional[str]
+
+
+class NoteHitModel(CamelCaseBaseModel):
+    filename: str
+    last_modified: int
+    title_highlights: Optional[str]
+    content_highlights: Optional[str]
+
+    @classmethod
+    def dump(self, note_hit: NoteHit) -> Dict:
+        return {
+            "filename": note_hit.filename,
+            "lastModified": note_hit.last_modified,
+            "titleHighlights": note_hit.title_highlights,
+            "contentHighlights": note_hit.content_highlights,
+        }
diff --git a/flatnotes/src/api.js b/flatnotes/src/api.js
new file mode 100644 (file)
index 0000000..b1912cf
--- /dev/null
@@ -0,0 +1,34 @@
+import axios from "axios";
+import EventBus from "./eventBus.js";
+
+const api = axios.create();
+
+api.interceptors.request.use(
+  function(config) {
+    if (config.url !== "/api/token") {
+      let token = sessionStorage.getItem("token");
+      config.headers.Authorization = `Bearer ${token}`;
+    }
+    return config;
+  },
+  function(error) {
+    return Promise.reject(error);
+  }
+);
+
+api.interceptors.response.use(
+  function(response) {
+    return response;
+  },
+  function(error) {
+    if (
+      typeof error.response !== "undefined" &&
+      error.response.status === 401
+    ) {
+      EventBus.$emit("logout");
+    }
+    return Promise.reject(error);
+  }
+);
+
+export default api;
index c40229ba2a2233dc3a360a96696d965723968972..e40dbebb297ef8e29082cc2a12caace087402bc6 100644 (file)
@@ -2,9 +2,10 @@ import "@toast-ui/editor/dist/toastui-editor.css";
 import "@toast-ui/editor/dist/toastui-editor-viewer.css";
 import { Editor } from "@toast-ui/vue-editor";
 import { Viewer } from "@toast-ui/vue-editor";
-import axios from "axios";
 
+import api from "../api";
 import { Note, SearchResult } from "./classes";
+import EventBus from "../eventBus";
 
 export default {
   components: {
@@ -14,6 +15,10 @@ export default {
 
   data: function() {
     return {
+      loggedIn: false,
+      usernameInput: null,
+      passwordInput: null,
+      rememberMeInput: false,
       notes: [],
       searchTerm: null,
       searchTimeout: null,
@@ -25,8 +30,12 @@ export default {
 
   computed: {
     currentView: function() {
+      // 4 - Login
+      if (this.loggedIn == false) {
+        return 4;
+      }
       // 3 - Edit Note
-      if (this.currentNote && this.editMode) {
+      else if (this.currentNote && this.editMode) {
         return 3;
       }
       // 2 - View Note
@@ -62,17 +71,45 @@ export default {
   },
 
   methods: {
+    login: function() {
+      let parent = this;
+      api
+        .post("/api/token", {
+          username: this.usernameInput,
+          password: this.passwordInput,
+        })
+        .then(function(response) {
+          sessionStorage.setItem("token", response.data.access_token);
+          if (parent.rememberMeInput == true) {
+            localStorage.setItem("token", response.data.access_token);
+          }
+          parent.loggedIn = true;
+          parent.getNotes();
+        })
+        .finally(function() {
+          parent.usernameInput = null;
+          parent.passwordInput = null;
+          parent.rememberMeInput = false;
+        });
+    },
+
+    logout: function() {
+      sessionStorage.removeItem("token");
+      localStorage.removeItem("token");
+      this.loggedIn = false;
+    },
+
     getNotes: function() {
-      parent = this;
+      let parent = this;
       parent.notes = [];
-      axios.get("/api/notes").then(function(response) {
+      api.get("/api/notes").then(function(response) {
         response.data.forEach(function(note) {
           parent.notes.push(new Note(note.filename, note.lastModified));
         });
       });
     },
 
-    clearSearchTimeout: function(params) {
+    clearSearchTimeout: function() {
       if (this.searchTimeout != null) {
         clearTimeout(this.searchTimeout);
       }
@@ -84,13 +121,13 @@ export default {
     },
 
     search: function() {
-      parent = this;
+      let parent = this;
       this.clearSearchTimeout();
-      this.searchResults = [];
       if (this.searchTerm) {
-        axios
+        api
           .get("/api/search", { params: { term: this.searchTerm } })
           .then(function(response) {
+            parent.searchResults = [];
             response.data.forEach(function(result) {
               parent.searchResults.push(
                 new SearchResult(
@@ -106,8 +143,8 @@ export default {
     },
 
     loadNote: function(filename) {
-      parent = this;
-      axios.get(`/api/notes/${filename}`).then(function(response) {
+      let parent = this;
+      api.get(`/api/notes/${filename}`).then(function(response) {
         parent.currentNote = response.data;
       });
     },
@@ -118,10 +155,10 @@ export default {
     },
 
     saveNote: function() {
-      parent = this;
+      let parent = this;
       let newContent = this.$refs.toastUiEditor.invoke("getMarkdown");
       if (newContent != this.currentNote.content) {
-        axios
+        api
           .patch(`/api/notes/${this.currentNote.filename}`, {
             newContent: newContent,
           })
@@ -136,6 +173,13 @@ export default {
   },
 
   created: function() {
-    this.getNotes();
+    EventBus.$on("logout", this.logout);
+
+    let token = localStorage.getItem("token");
+    if (token != null) {
+      sessionStorage.setItem("token", token);
+      this.loggedIn = true;
+      this.getNotes();
+    }
   },
 };
index 6f08ec58358ec67acac021c9482a4b76dad52238..03e8080ce4e643de2b7ec74c7fc93cb66399e4e6 100644 (file)
@@ -7,15 +7,22 @@
       </div>
 
       <!-- Buttons -->
-      <div
-        v-if="currentView == 2 || currentView == 3"
-        class="d-flex justify-content-center mb-4"
-      >
+      <div v-if="currentView != 4" class="d-flex justify-content-center mb-4">
+        <!-- Logout -->
+        <button
+          v-if="currentView == 0"
+          type="button"
+          class="btn btn-light mx-1"
+          @click="logout"
+        >
+          Logout
+        </button>
+
         <!-- Close -->
         <button
           v-if="currentView == 2"
           type="button"
-          class="btn btn-secondary"
+          class="btn btn-secondary mx-1"
           @click="unloadNote"
         >
           Close
@@ -25,7 +32,7 @@
         <button
           v-if="currentView == 2"
           type="button"
-          class="btn btn-warning ms-2"
+          class="btn btn-warning mx-1"
           @click="editMode = true"
         >
           Edit
@@ -35,7 +42,7 @@
         <button
           v-if="currentView == 3"
           type="button"
-          class="btn btn-secondary ms-2"
+          class="btn btn-secondary mx-1"
           @click="editMode = false"
         >
           Cancel
         <button
           v-if="currentView == 3"
           type="button"
-          class="btn btn-success ms-2"
+          class="btn btn-success mx-1"
           @click="saveNote"
         >
           Save
         </button>
       </div>
 
+      <!-- Login -->
+      <div v-if="currentView == 4" class="d-flex justify-content-center">
+        <form v-on:submit.prevent="login">
+          <div class="mb-3">
+            <label for="username" class="form-label">Username</label>
+            <input
+              type="text"
+              class="form-control"
+              id="username"
+              autocomplete="username"
+              v-model="usernameInput"
+            />
+          </div>
+          <div class="mb-3">
+            <label for="password" class="form-label">Password</label>
+            <input
+              type="password"
+              class="form-control"
+              id="password"
+              autocomplete="current-password"
+              v-model="passwordInput"
+            />
+          </div>
+          <div class="mb-3 form-check">
+            <input
+              type="checkbox"
+              class="form-check-input"
+              id="rememberMe"
+              v-model="rememberMeInput"
+            />
+            <label class="form-check-label" for="rememberMe">Remember Me</label>
+          </div>
+          <button type="submit" class="btn btn-primary">Log In</button>
+        </form>
+      </div>
+
       <!-- Viewer -->
-      <div v-if="currentView == 2">
+      <div v-else-if="currentView == 2">
         <viewer :initialValue="currentNote.content" height="600px" />
       </div>
 
       <!-- Front Page -->
       <div v-else>
         <!-- Search Input -->
-        <div class="form-group mb-4 d-flex justify-content-center">
-          <input
-            type="text"
-            class="form-control"
-            placeholder="Search"
-            v-model="searchTerm"
-            @change="search"
-            style="max-width: 500px"
-          />
-        </div>
+        <form v-on:submit.prevent="search">
+          <div class="form-group mb-4 d-flex justify-content-center">
+            <input
+              type="text"
+              class="form-control"
+              placeholder="Search"
+              v-model="searchTerm"
+              style="max-width: 500px"
+            />
+          </div>
+        </form>
 
         <!-- Search Results -->
         <div v-if="currentView == 1">
diff --git a/flatnotes/src/eventBus.js b/flatnotes/src/eventBus.js
new file mode 100644 (file)
index 0000000..dc7e25b
--- /dev/null
@@ -0,0 +1,3 @@
+import Vue from "vue";
+
+export default new Vue();
git clone https://git.99rst.org/PROJECT