Implement read-only mode
authorAdam Dullage <redacted>
Sat, 29 Jul 2023 07:30:19 +0000 (08:30 +0100)
committerAdam Dullage <redacted>
Sat, 29 Jul 2023 07:30:19 +0000 (08:30 +0100)
README.md
flatnotes/auth.py
flatnotes/config.py
flatnotes/main.py
flatnotes/src/components/App.vue
flatnotes/src/components/NavBar.vue
flatnotes/src/components/NoteViewerEditor.vue
flatnotes/src/constants.js

index 4054b7e836946a0eaa2fc96ff476dc81ad483b78..e74f7d03c6faf33f214a7324dbb75bcb827318e6 100644 (file)
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ Equally, the only thing flatnotes caches is the search index and that's incremen
 * Advanced search functionality.
 * Note "tagging" functionality.
 * Light/dark themes.
-* Multiple authentication options (none, username/password, 2FA).
+* Multiple authentication options (none, read only, username/password, 2FA).
 * Restful API.
 
 See [the wiki](https://github.com/dullage/flatnotes/wiki) for more details.
index 7ea6871c4dde7f5a010a9307e6171fe265f59fe9..e3d7b05b22e303dfef56cccb4282c23a67e6aefe 100644 (file)
@@ -44,16 +44,3 @@ def validate_token(token: str = Depends(oauth2_scheme)):
 
 def no_auth():
     return
-
-
-def get_auth(for_edit: bool = True):
-    if config.auth_type == AuthType.NONE:
-        return no_auth
-    elif (
-        config.auth_type
-        in [AuthType.PASSWORD_EDIT_ONLY, AuthType.TOTP_EDIT_ONLY]
-        and for_edit is False
-    ):
-        return no_auth
-    else:
-        return validate_token
index 482fb408b2c6ef5cc1f0758983fe745bed7495dd..80b03fbdbf8a9c62050d7072f3649527c2f75622 100644 (file)
@@ -8,10 +8,9 @@ from logger import logger
 
 class AuthType(str, Enum):
     NONE = "none"
+    READ_ONLY = "read_only"
     PASSWORD = "password"
-    PASSWORD_EDIT_ONLY = "password_edit_only"
     TOTP = "totp"
-    TOTP_EDIT_ONLY = "totp_edit_only"
 
 
 class Config:
index 79e10f2ad071e9962690df981cbf57ceb60077c9..f186f608f0f7a42ce01684147bdca785c57c2748 100644 (file)
@@ -7,7 +7,7 @@ from fastapi.responses import HTMLResponse
 from fastapi.staticfiles import StaticFiles
 from qrcode import QRCode
 
-from auth import create_access_token, get_auth, validate_token
+from auth import create_access_token, no_auth, validate_token
 from config import AuthType, config
 from error_responses import (
     invalid_title_response,
@@ -31,6 +31,11 @@ totp = (
 )
 last_used_totp = None
 
+if config.auth_type in [AuthType.NONE, AuthType.READ_ONLY]:
+    authenticate = no_auth
+else:
+    authenticate = validate_token
+
 # Display TOTP QR code
 if config.auth_type == AuthType.TOTP:
     uri = totp.provisioning_uri(issuer_name="flatnotes", name=config.username)
@@ -43,37 +48,41 @@ if config.auth_type == AuthType.TOTP:
     qr.print_ascii()
     print(f"Or manually enter this key: {totp.secret.decode('utf-8')}\n")
 
+if config.auth_type not in [AuthType.NONE, AuthType.READ_ONLY]:
 
-@app.post("/api/token")
-def token(data: LoginModel):
-    global last_used_totp
-
-    username_correct = secrets.compare_digest(
-        config.username.lower(), data.username.lower()
-    )
+    @app.post("/api/token")
+    def token(data: LoginModel):
+        global last_used_totp
 
-    expected_password = config.password
-    if config.auth_type == AuthType.TOTP:
-        current_totp = totp.now()
-        expected_password += current_totp
-    password_correct = secrets.compare_digest(expected_password, data.password)
-
-    if not (
-        username_correct
-        and password_correct
-        # Prevent TOTP from being reused
-        and (
-            config.auth_type != AuthType.TOTP or current_totp != last_used_totp
+        username_correct = secrets.compare_digest(
+            config.username.lower(), data.username.lower()
         )
-    ):
-        raise HTTPException(
-            status_code=400, detail="Incorrect login credentials."
+
+        expected_password = config.password
+        if config.auth_type == AuthType.TOTP:
+            current_totp = totp.now()
+            expected_password += current_totp
+        password_correct = secrets.compare_digest(
+            expected_password, data.password
         )
 
-    access_token = create_access_token(data={"sub": config.username})
-    if config.auth_type == AuthType.TOTP:
-        last_used_totp = current_totp
-    return {"access_token": access_token, "token_type": "bearer"}
+        if not (
+            username_correct
+            and password_correct
+            # Prevent TOTP from being reused
+            and (
+                config.auth_type != AuthType.TOTP
+                or current_totp != last_used_totp
+            )
+        ):
+            raise HTTPException(
+                status_code=400, detail="Incorrect login credentials."
+            )
+
+        access_token = create_access_token(data={"sub": config.username})
+        if config.auth_type == AuthType.TOTP:
+            last_used_totp = current_totp
+        return {"access_token": access_token, "token_type": "bearer"}
 
 
 @app.get("/")
@@ -87,26 +96,28 @@ def root(title: str = ""):
     return HTMLResponse(content=html)
 
 
-@app.post(
-    "/api/notes",
-    dependencies=[Depends(get_auth(for_edit=True))],
-    response_model=NoteModel,
-)
-def post_note(data: NoteModel):
-    """Create a new note."""
-    try:
-        note = Note(flatnotes, data.title, new=True)
-        note.content = data.content
-        return NoteModel.dump(note, include_content=True)
-    except InvalidTitleError:
-        return invalid_title_response
-    except FileExistsError:
-        return title_exists_response
+if config.auth_type != AuthType.READ_ONLY:
+
+    @app.post(
+        "/api/notes",
+        dependencies=[Depends(authenticate)],
+        response_model=NoteModel,
+    )
+    def post_note(data: NoteModel):
+        """Create a new note."""
+        try:
+            note = Note(flatnotes, data.title, new=True)
+            note.content = data.content
+            return NoteModel.dump(note, include_content=True)
+        except InvalidTitleError:
+            return invalid_title_response
+        except FileExistsError:
+            return title_exists_response
 
 
 @app.get(
     "/api/notes/{title}",
-    dependencies=[Depends(get_auth(for_edit=False))],
+    dependencies=[Depends(authenticate)],
     response_model=NoteModel,
 )
 def get_note(
@@ -123,43 +134,45 @@ def get_note(
         return note_not_found_response
 
 
-@app.patch(
-    "/api/notes/{title}",
-    dependencies=[Depends(get_auth(for_edit=True))],
-    response_model=NoteModel,
-)
-def patch_note(title: str, new_data: NotePatchModel):
-    try:
-        note = Note(flatnotes, title)
-        if new_data.new_title is not None:
-            note.title = new_data.new_title
-        if new_data.new_content is not None:
-            note.content = new_data.new_content
-        return NoteModel.dump(note, include_content=True)
-    except InvalidTitleError:
-        return invalid_title_response
-    except FileExistsError:
-        return title_exists_response
-    except FileNotFoundError:
-        return note_not_found_response
-
+if config.auth_type != AuthType.READ_ONLY:
 
-@app.delete(
-    "/api/notes/{title}", dependencies=[Depends(get_auth(for_edit=True))]
-)
-def delete_note(title: str):
-    try:
-        note = Note(flatnotes, title)
-        note.delete()
-    except InvalidTitleError:
-        return invalid_title_response
-    except FileNotFoundError:
-        return note_not_found_response
+    @app.patch(
+        "/api/notes/{title}",
+        dependencies=[Depends(authenticate)],
+        response_model=NoteModel,
+    )
+    def patch_note(title: str, new_data: NotePatchModel):
+        try:
+            note = Note(flatnotes, title)
+            if new_data.new_title is not None:
+                note.title = new_data.new_title
+            if new_data.new_content is not None:
+                note.content = new_data.new_content
+            return NoteModel.dump(note, include_content=True)
+        except InvalidTitleError:
+            return invalid_title_response
+        except FileExistsError:
+            return title_exists_response
+        except FileNotFoundError:
+            return note_not_found_response
+
+
+if config.auth_type != AuthType.READ_ONLY:
+
+    @app.delete("/api/notes/{title}", dependencies=[Depends(authenticate)])
+    def delete_note(title: str):
+        try:
+            note = Note(flatnotes, title)
+            note.delete()
+        except InvalidTitleError:
+            return invalid_title_response
+        except FileNotFoundError:
+            return note_not_found_response
 
 
 @app.get(
     "/api/tags",
-    dependencies=[Depends(get_auth(for_edit=False))],
+    dependencies=[Depends(authenticate)],
 )
 def get_tags():
     """Get a list of all indexed tags."""
@@ -168,7 +181,7 @@ def get_tags():
 
 @app.get(
     "/api/search",
-    dependencies=[Depends(get_auth(for_edit=False))],
+    dependencies=[Depends(authenticate)],
     response_model=List[SearchResultModel],
 )
 def search(
index 33729e4bae34b16c27ddb9b2ce46d512a14f829f..9397c8acbd2bca2ac45d94cfbdd2fad82c925243 100644 (file)
@@ -12,7 +12,7 @@
       v-if="currentView != views.login"
       class="w-100 mb-5"
       :show-logo="currentView != views.home"
-      :show-log-out="authType != null && authType != constants.authTypes.none"
+      :auth-type="authType"
       :dark-theme="darkTheme"
       @logout="logout()"
       @toggleTheme="toggleTheme()"
     <!-- Home -->
     <div
       v-if="currentView == views.home"
-      class="
-        home-view
-        align-self-center
-        d-flex
-        flex-column
-        justify-content-center
-        align-items-center
-        flex-grow-1
-        w-100
-      "
+      class="home-view align-self-center d-flex flex-column justify-content-center align-items-center flex-grow-1 w-100"
     >
       <Logo class="mb-3"></Logo>
       <SearchInput
@@ -67,6 +58,7 @@
       v-if="currentView == this.views.note"
       class="flex-grow-1"
       :titleToLoad="noteTitle"
+      :auth-type="authType"
       @note-deleted="noteDeletedToast"
     ></NoteViewerEditor>
   </div>
index 467d97301b3351a043d1c8f9d1dead2c996e4eaa..c0b82ef8b80f1082d702709dd43c3589a58a823f 100644 (file)
@@ -12,7 +12,7 @@
     <div class="d-flex">
       <!-- Log Out -->
       <button
-        v-if="showLogOut"
+        v-if="showLogOutButton"
         type="button"
         class="bttn"
         @click="$emit('logout')"
@@ -22,6 +22,7 @@
 
       <!-- New Note -->
       <a
+        v-if="showNewButton"
         :href="constants.basePaths.new"
         class="bttn"
         @click.prevent="navigate(constants.basePaths.new, $event)"
@@ -91,7 +92,7 @@ export default {
       type: Boolean,
       default: true,
     },
-    showLogOut: { type: Boolean, default: false },
+    authType: { type: String, default: null },
     darkTheme: { type: Boolean, default: false },
   },
 
@@ -103,6 +104,21 @@ export default {
       params.set(constants.params.showHighlights, false);
       return `${constants.basePaths.search}?${params.toString()}`;
     },
+
+    showLogOutButton: function () {
+      return (
+        this.authType != null &&
+        ![constants.authTypes.none, constants.authTypes.readOnly].includes(
+          this.authType
+        )
+      );
+    },
+
+    showNewButton: function () {
+      return (
+        this.authType != null && this.authType != constants.authTypes.readOnly
+      );
+    },
   },
 
   methods: {
index a7677c590c98548b57a570264b2e8212cc80683b..441971ca55c0c6dfc66c83282385e95b118ca6ed 100644 (file)
@@ -35,7 +35,7 @@
         <div class="d-flex">
           <!-- Edit -->
           <button
-            v-if="editMode == false && noteLoadFailed == false"
+            v-if="canModify && editMode == false && noteLoadFailed == false"
             type="button"
             class="bttn"
             @click="setEditMode(true)"
@@ -47,7 +47,7 @@
 
           <!-- Delete -->
           <button
-            v-if="editMode == false && noteLoadFailed == false"
+            v-if="canModify && editMode == false && noteLoadFailed == false"
             type="button"
             class="bttn"
             @click="deleteNote"
@@ -216,6 +216,7 @@ export default {
 
   props: {
     titleToLoad: { type: String, default: null },
+    authType: { type: String, default: null },
   },
 
   data: function () {
@@ -240,6 +241,14 @@ export default {
     };
   },
 
+  computed: {
+    canModify: function () {
+      return (
+        this.authType != null && this.authType != constants.authTypes.readOnly
+      );
+    },
+  },
+
   watch: {
     titleToLoad: function () {
       if (this.titleToLoad !== this.currentNote?.title) {
@@ -553,7 +562,7 @@ export default {
 
     // 'e' to edit
     Mousetrap.bind("e", function () {
-      if (parent.editMode == false) {
+      if (parent.editMode == false && parent.canModify) {
         parent.setEditMode(true);
       }
     });
index 34574665c0373007a8119932b9c841b9b6ce5ea8..ac41d31a38bf4e220b9cb6df95c963a286bf8122 100644 (file)
@@ -47,4 +47,9 @@ export const alphabet = [
 
 export const searchSortOptions = { score: 0, title: 1, lastModified: 2 };
 
-export const authTypes = { none: "none", password: "password", totp: "totp" };
+export const authTypes = {
+  none: "none",
+  readOnly: "read_only",
+  password: "password",
+  totp: "totp",
+};
git clone https://git.99rst.org/PROJECT