diff --git a/backend/api/api.go b/backend/api/api.go index b6c16ab..86f49a8 100644 --- a/backend/api/api.go +++ b/backend/api/api.go @@ -3,6 +3,9 @@ package api import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "vortexnotes/backend/api/auth" + "vortexnotes/backend/api/configuration" + "vortexnotes/backend/api/middlewares" "vortexnotes/backend/api/notes" "vortexnotes/backend/api/website" "vortexnotes/backend/config" @@ -10,7 +13,10 @@ import ( func Start() { server := gin.Default() - server.Use(cors.Default()) + corsConfig := cors.DefaultConfig() + corsConfig.AllowAllOrigins = true + corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization"} + server.Use(cors.New(corsConfig)) server.GET("/", website.ServeRoot) server.GET("/assets/*filepath", website.ServeAssets) @@ -20,12 +26,14 @@ func Start() { api := server.Group("/api") { - api.GET("/notes", notes.ListAllNotes) - api.GET("/notes/:id", notes.GetNote) - api.DELETE("/notes/:id", notes.DeleteNote) - api.PATCH("/notes/:id", notes.UpdateNote) - api.POST("/notes/new", notes.CreateNote) - api.GET("/search", notes.SearchNotes) + api.GET("/config", configuration.Config) + api.POST("/auth", auth.Auth) + api.GET("/search", middlewares.HasPermission("show"), notes.SearchNotes) + api.GET("/notes", middlewares.HasPermission("show"), notes.ListAllNotes) + api.GET("/notes/:id", middlewares.HasPermission("show"), notes.GetNote) + api.DELETE("/notes/:id", middlewares.HasPermission("delete"), notes.DeleteNote) + api.PATCH("/notes/:id", middlewares.HasPermission("edit"), notes.UpdateNote) + api.POST("/notes/new", middlewares.HasPermission("create"), notes.CreateNote) } server.SetTrustedProxies(nil) diff --git a/backend/api/auth/auth.go b/backend/api/auth/auth.go new file mode 100644 index 0000000..91e35b1 --- /dev/null +++ b/backend/api/auth/auth.go @@ -0,0 +1,36 @@ +package auth + +import ( + "github.com/gin-gonic/gin" + "net/http" + "os" +) + +func Auth(c *gin.Context) { + passcode := os.Getenv("VORTEXNOTES_PASSCODE") + + type RequestData struct { + Passcode string `json:"passcode"` + } + + var requestData RequestData + + if err := c.BindJSON(&requestData); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) + return + } + + if requestData.Passcode != passcode { + c.JSON(http.StatusBadRequest, gin.H{"message": "ePasscode Invalid"}) + return + } + + authScopes := os.Getenv("VORTEXNOTES_AUTH_SCOPE") + if authScopes == "" { + authScopes = "show,create,edit,delete" + } + + c.JSON(http.StatusOK, gin.H{ + "auth_scope": authScopes, + }) +} diff --git a/backend/api/configuration/configuration.go b/backend/api/configuration/configuration.go new file mode 100644 index 0000000..3049175 --- /dev/null +++ b/backend/api/configuration/configuration.go @@ -0,0 +1,26 @@ +package configuration + +import ( + "github.com/gin-gonic/gin" + "net/http" + "os" +) + +func Config(c *gin.Context) { + needAuthScopes := os.Getenv("VORTEXNOTES_AUTH_SCOPE") + passcode := os.Getenv("VORTEXNOTES_PASSCODE") + auth := "none" + + if needAuthScopes == "" { + needAuthScopes = "show,create,edit,delete" + } + + if passcode != "" { + auth = "passcode" + } + + c.JSON(http.StatusOK, gin.H{ + "auth_scope": needAuthScopes, + "auth": auth, + }) +} diff --git a/backend/api/middlewares/middlewares.go b/backend/api/middlewares/middlewares.go new file mode 100644 index 0000000..897b20b --- /dev/null +++ b/backend/api/middlewares/middlewares.go @@ -0,0 +1,35 @@ +package middlewares + +import ( + "github.com/gin-gonic/gin" + "net/http" + "os" + "strings" +) + +func HasPermission(scope string) gin.HandlerFunc { + return func(c *gin.Context) { + expectedPasscode := os.Getenv("VORTEXNOTES_PASSCODE") + authorizationHeader := c.GetHeader("Authorization") + passcode := strings.TrimPrefix(authorizationHeader, "Bearer ") + + needAuthScopes := os.Getenv("VORTEXNOTES_AUTH_SCOPE") + if needAuthScopes == "" { + needAuthScopes = "show,create,edit,delete" + } + + if expectedPasscode == "" { + c.Next() + return + } + + if strings.Contains(needAuthScopes, scope) { + if passcode != expectedPasscode { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Unauthorized"}) + return + } + } + + c.Next() + } +} diff --git a/backend/api/notes/notes.go b/backend/api/notes/notes.go index 9c17f96..6e6c8b6 100644 --- a/backend/api/notes/notes.go +++ b/backend/api/notes/notes.go @@ -63,7 +63,7 @@ func CreateNote(c *gin.Context) { var requestData RequestData if err := c.BindJSON(&requestData); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()}) return } @@ -156,7 +156,7 @@ func UpdateNote(c *gin.Context) { err := indexer.DeleteNote(id) if err != nil { c.JSON(http.StatusNotFound, gin.H{ - "error": err, + "message": err, }) return } diff --git a/backend/utils/common.go b/backend/utils/common.go index ae56732..058c8d9 100644 --- a/backend/utils/common.go +++ b/backend/utils/common.go @@ -20,3 +20,12 @@ func FileExists(filePath string) bool { _, err := os.Stat(filePath) return !os.IsNotExist(err) } + +func ArrayContainsString(arr []string, target string) bool { + for _, str := range arr { + if str == target { + return true + } + } + return false +} diff --git a/docker-compose.yml b/docker-compose.yml index 59114e5..e1cd828 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,9 @@ services: vortexnotes: container_name: vortexnotes image: vortexnotes:latest + environment: + VORTEXNOTES_PASSCODE: 123456 + VORTEXNOTES_AUTH_SCOPE: create,edit,delete ports: - "0.0.0.0:7701:7701" volumes: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4b2e875..00f0b88 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,8 @@ import '@uiw/react-markdown-preview/markdown.css' import NotesScreen from '@/screens/NotesScreen' import TopViewContainer from '@/components/TopView.tsx' import Root from '@/components/Root.tsx' +import AuthScreen from '@/screens/AuthScreen' +import InitScreen from '@/screens/InitScreen' const router = createBrowserRouter([ { @@ -41,6 +43,14 @@ const router = createBrowserRouter([ element: } ] + }, + { + path: '/init', + element: + }, + { + path: '/auth', + element: } ]) diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 937b717..5bbabfb 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,7 +1,7 @@ import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom' import React, { useEffect, useState } from 'react' import useTheme from '@/hooks/useTheme.ts' -import { onSearch as search } from '@/utils' +import { hasPasscode, hasPermission, onSearch as search } from '@/utils' interface Props {} @@ -20,6 +20,10 @@ const Navbar: React.FC = () => { setInput(keywords) }, [keywords]) + const onLogout = () => { + localStorage.removeItem('vortexnotes_passcode') + } + const navbarBg = isHome ? '' : 'bg-white dark:bg-black dark:bg-transparent-20' const navbarBorder = isHome ? 'border-b border-transparent' @@ -92,12 +96,56 @@ const Navbar: React.FC = () => { - -
- - -
- + {hasPermission('create') && ( + + + + )} +
+ +
    +
  • + +
    + + All Notes +
    + +
  • + {!hasPasscode() && ( +
  • + +
    + + Login +
    + +
  • + )} + {hasPasscode() && ( +
  • + +
    + + Logout +
    + +
  • + )} +
+
diff --git a/frontend/src/components/Root.tsx b/frontend/src/components/Root.tsx index 7e6e275..26989da 100644 --- a/frontend/src/components/Root.tsx +++ b/frontend/src/components/Root.tsx @@ -1,8 +1,25 @@ -import { Outlet } from 'react-router-dom' +import { Outlet, useNavigate } from 'react-router-dom' import Navbar from '@/components/Navbar.tsx' -import React from 'react' +import React, { useEffect } from 'react' +import { fetchConfig } from '@/utils/api.ts' const Root: React.FC = () => { + const navigate = useNavigate() + + useEffect(() => { + const onLoad = async () => { + if (!localStorage.vortexnotes_auth_scope) { + navigate('/init', { replace: true }) + } else { + await fetchConfig() + } + } + + window.addEventListener('load', onLoad) + + return () => window.removeEventListener('load', onLoad) + }, [navigate]) + return (
diff --git a/frontend/src/config/http.ts b/frontend/src/config/http.ts index 2c7a971..339f6c8 100644 --- a/frontend/src/config/http.ts +++ b/frontend/src/config/http.ts @@ -1,7 +1,30 @@ import axios from 'axios' -const http = axios.create({ - baseURL: location.origin.replace('7702', '7701') + '/api/' -}) +export const getAxiosInstance = () => { + const instance = axios.create({ + baseURL: location.origin.replace('7702', '7701') + '/api/', + headers: { + Authorization: localStorage.vortexnotes_passcode + ? 'Bearer ' + localStorage.vortexnotes_passcode + : undefined + } + }) -window.$http = http + instance.interceptors.response.use( + response => response, + error => { + if (error.response) { + if (error.response.status === 401) { + localStorage.removeItem('vortexnotes_passcode') + localStorage.removeItem('vortexnotes_auth_scope') + location.href = '/auth' + } + } + return Promise.reject(error) + } + ) + + return instance +} + +window.$http = getAxiosInstance() diff --git a/frontend/src/hooks/usePermissionCheckEffect.ts b/frontend/src/hooks/usePermissionCheckEffect.ts new file mode 100644 index 0000000..68e7980 --- /dev/null +++ b/frontend/src/hooks/usePermissionCheckEffect.ts @@ -0,0 +1,13 @@ +import { useEffect } from 'react' +import { hasPermission } from '@/utils' +import { useNavigate } from 'react-router-dom' + +export default function usePermissionCheckEffect(scope: string) { + const navigate = useNavigate() + + useEffect(() => { + if (!hasPermission(scope)) { + navigate('/auth') + } + }, [navigate, scope]) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index f178957..5562f8d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,5 @@ @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@200;300;400;500;600;700&display=swap'); -@import url('https://at.alicdn.com/t/c/font_4344275_jt0ar688xla.css'); +@import url('https://at.alicdn.com/t/c/font_4344275_ibfdwgo51bi.css'); @tailwind base; @tailwind components; diff --git a/frontend/src/screens/AuthScreen/index.tsx b/frontend/src/screens/AuthScreen/index.tsx new file mode 100644 index 0000000..3ba0b05 --- /dev/null +++ b/frontend/src/screens/AuthScreen/index.tsx @@ -0,0 +1,94 @@ +import React, { useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { isAxiosError } from 'axios' +import { getAxiosInstance } from '@/config/http.ts' +import ToastPopup from '@/components/popups/ToastPopup.tsx' + +const AuthScreen: React.FC = () => { + const [passcode, setPasscode] = useState('') + const [loading, setLoading] = useState(false) + const navigate = useNavigate() + const inputRef = useRef(null) + const [error, setError] = useState(false) + + const onAuth = async () => { + if (loading) { + return + } + + if (passcode.trim().length === 0) { + return inputRef.current?.focus() + } + + setLoading(true) + + try { + const res = await window.$http.post('auth', { passcode }) + localStorage.vortexnotes_passcode = passcode + localStorage.vortexnotes_auth_scope = res.data.auth_scope + window.$http = getAxiosInstance() + navigate('/') + ToastPopup.show({ message: 'Authentication successful' }) + } catch (error) { + setLoading(false) + setError(true) + + if (isAxiosError(error)) { + if (error.response) { + setPasscode('') + return ToastPopup.show({ message: error.response.data?.message }) + } + } + + return ToastPopup.show({ message: 'Auth Failure' }) + } + } + + const inputErrorClass = error && 'border-red-500 focus:border-red-500 placeholder-red-500' + + return ( +
+
+
+
+ + + Vortexnotes + +
+
+ e.key === 'Enter' && onAuth()} + onChange={e => { + setPasscode(e.target.value) + error && setError(false) + }} + /> + +
+
+
+
+ ) +} + +export default AuthScreen diff --git a/frontend/src/screens/EditNoteScreen/index.ts b/frontend/src/screens/EditNoteScreen/index.ts deleted file mode 100644 index 671295f..0000000 --- a/frontend/src/screens/EditNoteScreen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './EditNoteScreen.tsx' diff --git a/frontend/src/screens/EditNoteScreen/EditNoteScreen.tsx b/frontend/src/screens/EditNoteScreen/index.tsx similarity index 97% rename from frontend/src/screens/EditNoteScreen/EditNoteScreen.tsx rename to frontend/src/screens/EditNoteScreen/index.tsx index 4f73af5..b7126b6 100644 --- a/frontend/src/screens/EditNoteScreen/EditNoteScreen.tsx +++ b/frontend/src/screens/EditNoteScreen/index.tsx @@ -6,6 +6,7 @@ import useRequest from '@/hooks/useRequest.ts' import { NoteType } from '@/types' import MDEditor from '@uiw/react-md-editor' import AlertPopup from '@/components/popups/AlertPopup.tsx' +import usePermissionCheckEffect from '@/hooks/usePermissionCheckEffect.ts' const EditNoteScreen: React.FC = () => { const params = useParams() @@ -16,6 +17,7 @@ const EditNoteScreen: React.FC = () => { const [edited, setEdited] = useState(false) const [saving, setSaving] = useState(false) const { data } = useRequest({ method: 'GET', url: `notes/${id}` }) + const navigate = useNavigate() useEffect(() => { if (data) { @@ -24,11 +26,11 @@ const EditNoteScreen: React.FC = () => { } }, [data]) + usePermissionCheckEffect('edit') + const titleInputRef = useRef(null) const contentInputRef = useRef(null) - const navigate = useNavigate() - const blocker = useBlocker( ({ currentLocation, nextLocation }) => !saving && edited && currentLocation.pathname !== nextLocation.pathname diff --git a/frontend/src/screens/HomeScreen/index.ts b/frontend/src/screens/HomeScreen/index.ts deleted file mode 100644 index cce5bb9..0000000 --- a/frontend/src/screens/HomeScreen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './HomeScreen' diff --git a/frontend/src/screens/HomeScreen/HomeScreen.tsx b/frontend/src/screens/HomeScreen/index.tsx similarity index 100% rename from frontend/src/screens/HomeScreen/HomeScreen.tsx rename to frontend/src/screens/HomeScreen/index.tsx diff --git a/frontend/src/screens/InitScreen/index.tsx b/frontend/src/screens/InitScreen/index.tsx new file mode 100644 index 0000000..2ed70ba --- /dev/null +++ b/frontend/src/screens/InitScreen/index.tsx @@ -0,0 +1,20 @@ +import LoadingView from '@/components/LoadingView.tsx' +import React, { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { fetchConfig } from '@/utils/api.ts' + +const InitScreen: React.FC = () => { + const navigate = useNavigate() + + useEffect(() => { + fetchConfig().then(() => navigate('/', { replace: true })) + }, [navigate]) + + return ( +
+ +
+ ) +} + +export default InitScreen diff --git a/frontend/src/screens/NewNoteScreen/index.ts b/frontend/src/screens/NewNoteScreen/index.ts deleted file mode 100644 index ed61af7..0000000 --- a/frontend/src/screens/NewNoteScreen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './NewNoteScreen.tsx' diff --git a/frontend/src/screens/NewNoteScreen/NewNoteScreen.tsx b/frontend/src/screens/NewNoteScreen/index.tsx similarity index 97% rename from frontend/src/screens/NewNoteScreen/NewNoteScreen.tsx rename to frontend/src/screens/NewNoteScreen/index.tsx index 0481038..ebd025a 100644 --- a/frontend/src/screens/NewNoteScreen/NewNoteScreen.tsx +++ b/frontend/src/screens/NewNoteScreen/index.tsx @@ -4,6 +4,7 @@ import { useBlocker, useNavigate } from 'react-router-dom' import MDEditor from '@uiw/react-md-editor' import { isAxiosError } from 'axios' import AlertPopup from '@/components/popups/AlertPopup.tsx' +import usePermissionCheckEffect from '@/hooks/usePermissionCheckEffect.ts' const NewNoteScreen: React.FC = () => { const [title, setTitle] = useState('') @@ -20,6 +21,8 @@ const NewNoteScreen: React.FC = () => { !saving && isEdited && currentLocation.pathname !== nextLocation.pathname ) + usePermissionCheckEffect('create') + useEffect(() => { if (blocker.state === 'blocked') { if (confirm('You have unsaved edits. Are you sure you want to leave?')) { diff --git a/frontend/src/screens/NoteScreen/index.ts b/frontend/src/screens/NoteScreen/index.ts deleted file mode 100644 index 4e1d0af..0000000 --- a/frontend/src/screens/NoteScreen/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './NoteScreen.tsx' diff --git a/frontend/src/screens/NoteScreen/NoteScreen.tsx b/frontend/src/screens/NoteScreen/index.tsx similarity index 70% rename from frontend/src/screens/NoteScreen/NoteScreen.tsx rename to frontend/src/screens/NoteScreen/index.tsx index ee967a4..fbefcf7 100644 --- a/frontend/src/screens/NoteScreen/NoteScreen.tsx +++ b/frontend/src/screens/NoteScreen/index.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { displayName } from '@/utils' +import { displayName, hasPermission } from '@/utils' import { isAxiosError } from 'axios' import { NoteType } from '@/types' import useRequest from '@/hooks/useRequest.ts' @@ -59,20 +59,24 @@ const NoteScreen: React.FC = () => { <>

{displayName(note.name)}

- - + {hasPermission('edit') && ( + + )} + {hasPermission('delete') && ( + + )}
{ const [empty, setEmpty] = useState(false) const [loading, setLoading] = useState(false) const navigate = useNavigate() + useEffect(() => { runAsyncFunction(async () => { setLoading(true) diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts new file mode 100644 index 0000000..d7c4384 --- /dev/null +++ b/frontend/src/utils/api.ts @@ -0,0 +1,10 @@ +export async function fetchConfig() { + const res = await window.$http.get('config') + const authScopes = res.data?.auth_scope + + if (authScopes) { + localStorage.vortexnotes_auth_scope = authScopes + } + + return res.data +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 3ac9c42..35e619c 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -33,3 +33,17 @@ export function onSearch(keywords: string, navigate: NavigateFunction) { navigate(`/search?keywords=${searchWords}`) } + +export const hasPermission = (scope: string) => { + if (!localStorage.vortexnotes_passcode) { + if (localStorage.vortexnotes_auth_scope?.includes(scope)) { + return false + } + } + + return true +} + +export const hasPasscode = () => { + return !!localStorage.vortexnotes_passcode +} diff --git a/vortexnotes.run.xml b/vortexnotes.run.xml index 7011cc3..644a048 100644 --- a/vortexnotes.run.xml +++ b/vortexnotes.run.xml @@ -2,6 +2,10 @@ + + + +