Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 39 additions & 14 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,17 @@ def safe_error_message(error: Exception, user_message: str = "An error occurred"
DEMO_MODE = os.getenv('DEMO_MODE', 'false').lower() in ('true', '1', 'yes')
ALREADY_DONATED = os.getenv('ALREADY_DONATED', 'false').lower() in ('true', '1', 'yes')

# Read-only mode - blocks all write operations when enabled
# Set READ_ONLY=true env var or read_only.enabled: true in config.yaml
_read_only_config = os.getenv('READ_ONLY', '').lower()
if _read_only_config in ('true', '1', 'yes'):
READ_ONLY = True
elif _read_only_config in ('false', '0', 'no'):
READ_ONLY = False
else:
READ_ONLY = config.get('read_only', {}).get('enabled', False)
print(f"📖 Read-only mode {'ENABLED' if READ_ONLY else 'DISABLED'} ({'READ_ONLY env var' if _read_only_config else 'config.yaml'})")

if DEMO_MODE:
# Enable rate limiting for demo deployments
limiter = Limiter(key_func=get_remote_address, default_limits=["200/hour"])
Expand Down Expand Up @@ -284,12 +295,18 @@ async def require_auth(request: Request):
"""Dependency to require authentication on protected routes"""
if not auth_enabled():
return # Auth disabled, allow all

if not request.session.get('authenticated'):
# Always raise exception - route handlers will catch and redirect as needed
raise HTTPException(status_code=401, detail="Not authenticated")


async def require_write_access():
"""Dependency to block write operations in read-only mode"""
if READ_ONLY:
raise HTTPException(status_code=403, detail="Read-only mode is enabled")


def verify_password(password: str) -> bool:
"""Verify password against stored hash"""
password_hash = config.get('authentication', {}).get('password_hash', '')
Expand Down Expand Up @@ -365,6 +382,12 @@ async def logout(request: Request):
dependencies=[Depends(require_auth)] # Apply auth to ALL routes in this router
)

# Create write router - requires auth AND blocks requests in read-only mode
write_router = APIRouter(
prefix="/api",
dependencies=[Depends(require_auth), Depends(require_write_access)]
)

# Create pages router with authentication dependency applied globally
pages_router = APIRouter(
dependencies=[Depends(require_auth)] # Apply auth to ALL routes in this router
Expand All @@ -384,6 +407,7 @@ async def get_config():
"searchEnabled": config['search']['enabled'],
"demoMode": DEMO_MODE, # Expose demo mode flag to frontend
"alreadyDonated": ALREADY_DONATED, # Hide support buttons if true
"readOnly": READ_ONLY, # Expose read-only mode flag to frontend
"authentication": {
"enabled": config.get('authentication', {}).get('enabled', False)
}
Expand Down Expand Up @@ -454,7 +478,7 @@ async def get_locale(locale_code: str):
raise HTTPException(status_code=500, detail=f"Failed to load locale: {str(e)}")


@api_router.post("/folders", tags=["Folders"])
@write_router.post("/folders", tags=["Folders"])
@limiter.limit("30/minute")
async def create_new_folder(request: Request, data: dict):
"""Create a new folder"""
Expand Down Expand Up @@ -510,7 +534,7 @@ async def get_media(media_path: str):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load media file"))


@api_router.post("/upload-media", tags=["Media"])
@write_router.post("/upload-media", tags=["Media"])
@limiter.limit("20/minute")
async def upload_media(request: Request, file: UploadFile = File(...), note_path: str = Form(...)):
"""
Expand Down Expand Up @@ -587,7 +611,7 @@ async def upload_media(request: Request, file: UploadFile = File(...), note_path
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to upload file"))


@api_router.post("/media/move", tags=["Media"])
@write_router.post("/media/move", tags=["Media"])
@limiter.limit("30/minute")
async def move_media_endpoint(request: Request, data: dict):
"""Move a media file to a different folder"""
Expand Down Expand Up @@ -637,7 +661,7 @@ async def move_media_endpoint(request: Request, data: dict):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move media file"))


@api_router.post("/notes/move", tags=["Notes"])
@write_router.post("/notes/move", tags=["Notes"])
@limiter.limit("30/minute")
async def move_note_endpoint(request: Request, data: dict):
"""Move a note to a different folder"""
Expand Down Expand Up @@ -671,7 +695,7 @@ async def move_note_endpoint(request: Request, data: dict):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move note"))


@api_router.post("/folders/move", tags=["Folders"])
@write_router.post("/folders/move", tags=["Folders"])
@limiter.limit("20/minute")
async def move_folder_endpoint(request: Request, data: dict):
"""Move a folder to a different location"""
Expand Down Expand Up @@ -699,7 +723,7 @@ async def move_folder_endpoint(request: Request, data: dict):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move folder"))


@api_router.post("/folders/rename", tags=["Folders"])
@write_router.post("/folders/rename", tags=["Folders"])
@limiter.limit("30/minute")
async def rename_folder_endpoint(request: Request, data: dict):
"""Rename a folder"""
Expand Down Expand Up @@ -727,7 +751,7 @@ async def rename_folder_endpoint(request: Request, data: dict):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to rename folder"))


@api_router.delete("/folders/{folder_path:path}", tags=["Folders"])
@write_router.delete("/folders/{folder_path:path}", tags=["Folders"])
@limiter.limit("20/minute")
async def delete_folder_endpoint(request: Request, folder_path: str):
"""Delete a folder and all its contents"""
Expand Down Expand Up @@ -836,7 +860,7 @@ async def get_template(request: Request, template_name: str):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get template"))


@api_router.post("/templates/create-note", tags=["Templates"])
@write_router.post("/templates/create-note", tags=["Templates"])
@limiter.limit("60/minute")
async def create_note_from_template(request: Request, data: dict):
"""
Expand Down Expand Up @@ -931,7 +955,7 @@ async def get_note(note_path: str):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load note"))


@api_router.post("/notes/{note_path:path}", tags=["Notes"])
@write_router.post("/notes/{note_path:path}", tags=["Notes"])
@limiter.limit("60/minute")
async def create_or_update_note(request: Request, note_path: str, content: dict):
"""Create or update a note"""
Expand Down Expand Up @@ -972,7 +996,7 @@ async def create_or_update_note(request: Request, note_path: str, content: dict)
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to save note"))


@api_router.delete("/notes/{note_path:path}", tags=["Notes"])
@write_router.delete("/notes/{note_path:path}", tags=["Notes"])
@limiter.limit("30/minute")
async def remove_note(request: Request, note_path: str):
"""Delete a note"""
Expand Down Expand Up @@ -1180,7 +1204,7 @@ async def calculate_note_stats(content: str):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to calculate note statistics"))


@api_router.post("/plugins/{plugin_name}/toggle", tags=["Plugins"])
@write_router.post("/plugins/{plugin_name}/toggle", tags=["Plugins"])
@limiter.limit("10/minute")
async def toggle_plugin(request: Request, plugin_name: str, enabled: dict):
"""Enable or disable a plugin"""
Expand All @@ -1204,7 +1228,7 @@ async def toggle_plugin(request: Request, plugin_name: str, enabled: dict):
# Share Token Endpoints (authenticated)
# ============================================================================

@api_router.post("/share/{note_path:path}", tags=["Sharing"])
@write_router.post("/share/{note_path:path}", tags=["Sharing"])
@limiter.limit("30/minute")
async def create_share(request: Request, note_path: str, data: dict = None):
"""
Expand Down Expand Up @@ -1292,7 +1316,7 @@ async def list_shared_notes(request: Request):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get shared notes"))


@api_router.delete("/share/{note_path:path}", tags=["Sharing"])
@write_router.delete("/share/{note_path:path}", tags=["Sharing"])
@limiter.limit("30/minute")
async def delete_share(request: Request, note_path: str):
"""
Expand Down Expand Up @@ -1429,6 +1453,7 @@ async def catch_all(full_path: str, request: Request):
# Register routers with the main app
# Authentication is applied via router dependencies
app.include_router(api_router)
app.include_router(write_router)
app.include_router(pages_router)


Expand Down
5 changes: 5 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ storage:
search:
enabled: true

read_only:
# Read-only mode - prevents all write operations (create, edit, delete, upload)
# Can also be set via READ_ONLY environment variable
enabled: false

authentication:
# Authentication settings
# Set enabled to true to require login
Expand Down
10 changes: 9 additions & 1 deletion frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ function noteApp() {
appVersion: '0.0.0',
authEnabled: false,
demoMode: false,
readOnly: false,
alreadyDonated: false,
notes: [],
currentNote: '',
Expand Down Expand Up @@ -711,7 +712,11 @@ function noteApp() {
this.appVersion = config.version || '0.0.0';
this.authEnabled = config.authentication?.enabled || false;
this.demoMode = config.demoMode || false;
this.readOnly = config.readOnly || false;
this.alreadyDonated = config.alreadyDonated || false;
if (this.readOnly) {
this.viewMode = 'preview';
}
} catch (error) {
console.error('Failed to load config:', error);
}
Expand Down Expand Up @@ -2173,7 +2178,8 @@ function noteApp() {
async onEditorDrop(event) {
event.preventDefault();
this.dropTarget = null;

if (this.readOnly) return;

// Check if files are being dropped (media from file system)
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
await this.handleMediaDrop(event);
Expand Down Expand Up @@ -2320,6 +2326,7 @@ function noteApp() {
// Handle paste event for clipboard media (images)
async handlePaste(event) {
if (!this.currentNote) return;
if (this.readOnly) return;

const items = event.clipboardData?.items;
if (!items) return;
Expand Down Expand Up @@ -5519,6 +5526,7 @@ function noteApp() {

// Toggle Zen Mode (full immersive writing experience)
async toggleZenMode() {
if (this.readOnly) return;
if (!this.zenMode) {
// Entering Zen Mode
this.previousViewMode = this.viewMode;
Expand Down
30 changes: 20 additions & 10 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1426,7 +1426,8 @@
<!-- Header with + New -->
<div class="flex-shrink-0 px-2 py-2 border-b flex items-center justify-between" style="border-color: var(--border-primary);">
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-tertiary);" x-text="t('sidebar.files')"></span>
<button
<button
x-show="!readOnly"
@click="dropdownTargetFolder = ''; toggleNewDropdown($event)"
class="px-2 py-1 text-xs font-medium text-white rounded focus:outline-none flex items-center gap-1"
style="background-color: var(--accent-primary);"
Expand Down Expand Up @@ -2005,7 +2006,8 @@
</svg>
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary);" x-text="t('homepage.no_notes_title')"></h2>
<p class="mb-6" style="color: var(--text-secondary);" x-text="t('homepage.no_notes_desc')"></p>
<button
<button
x-show="!readOnly"
@click="dropdownTargetFolder = selectedHomepageFolder; toggleNewDropdown($event)"
class="px-6 py-3 text-sm font-medium text-white rounded-lg transition-colors"
style="background-color: var(--accent-primary);"
Expand Down Expand Up @@ -2066,7 +2068,8 @@ <h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary);" x-text="
<span x-text="homepageFolders().length"></span>
<span x-text="homepageFolders().length === 1 ? t('homepage.folder_singular') : t('homepage.folder_plural')"></span>
</span>
<button
<button
x-show="!readOnly"
@click="dropdownTargetFolder = selectedHomepageFolder; toggleNewDropdown($event)"
class="ml-auto px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors"
style="background-color: var(--accent-primary);"
Expand All @@ -2087,7 +2090,8 @@ <h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary);" x-text="
</svg>
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary);" x-text="selectedHomepageFolder ? t('homepage.folder_empty') : t('homepage.no_notes_title')"></h2>
<p class="mb-6" style="color: var(--text-secondary);" x-text="selectedHomepageFolder ? t('homepage.get_started') : t('homepage.no_notes_desc')"></p>
<button
<button
x-show="!readOnly"
@click="dropdownTargetFolder = selectedHomepageFolder; toggleNewDropdown($event)"
class="px-6 py-3 text-sm font-medium text-white rounded-lg transition-colors"
style="background-color: var(--accent-primary);"
Expand All @@ -2112,7 +2116,8 @@ <h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary);" x-text="
onmouseout="this.style.borderColor='var(--accent-primary)'; this.style.transform='translateY(0)'; this.querySelector('.card-delete-btn').style.opacity='0';"
>
<!-- Delete Button -->
<button
<button
x-show="!readOnly"
@click.stop="deleteFolder(folder.path, folder.name)"
class="card-delete-btn hidden sm:block"
style="position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity 0.2s; color: var(--error); padding: 4px; border-radius: 4px; background-color: var(--bg-secondary);"
Expand Down Expand Up @@ -2153,7 +2158,8 @@ <h3 class="font-semibold text-sm sm:text-base" style="color: var(--text-primary)
onmouseout="this.style.borderColor='var(--border-primary)'; this.style.transform='translateY(0)'; this.querySelector('.card-delete-btn').style.opacity='0';"
>
<!-- Delete Button -->
<button
<button
x-show="!readOnly"
@click.stop="note.type !== 'note' ? deleteMedia(note.path) : deleteNote(note.path, note.name)"
class="card-delete-btn hidden sm:block"
style="position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity 0.2s; color: var(--error); padding: 4px; border-radius: 4px; background-color: var(--bg-secondary);"
Expand Down Expand Up @@ -2249,7 +2255,8 @@ <h3 class="font-semibold text-sm sm:text-base mb-1 sm:mb-2" style="color: var(--
</button>

<!-- Delete Button -->
<button
<button
x-show="!readOnly"
@click="currentMedia ? deleteMedia(currentMedia) : deleteCurrentNote()"
class="p-2 rounded-lg"
style="color: var(--error);"
Expand Down Expand Up @@ -2277,7 +2284,8 @@ <h3 class="font-semibold text-sm sm:text-base mb-1 sm:mb-2" style="color: var(--
<div class="flex items-center space-x-2" x-show="currentNote">
<!-- View Toggle (only for notes) -->
<div class="flex rounded-lg p-1" style="background-color: var(--bg-tertiary);">
<button
<button
x-show="!readOnly"
@click="viewMode = 'edit'"
class="px-2 md:px-3 py-1 text-xs md:text-sm rounded transition"
:style="viewMode === 'edit' ? 'background-color: var(--accent-primary); color: white;' : 'color: var(--text-secondary);'"
Expand All @@ -2286,7 +2294,8 @@ <h3 class="font-semibold text-sm sm:text-base mb-1 sm:mb-2" style="color: var(--
x-text="t('editor.mode_edit')"
>
</button>
<button
<button
x-show="!readOnly"
@click="viewMode = 'split'"
class="mobile-hide-split px-2 md:px-3 py-1 text-xs md:text-sm rounded transition"
:style="viewMode === 'split' ? 'background-color: var(--accent-primary); color: white;' : 'color: var(--text-secondary);'"
Expand Down Expand Up @@ -2376,7 +2385,8 @@ <h3 class="font-semibold text-sm sm:text-base mb-1 sm:mb-2" style="color: var(--
</svg>
</button>
<div style="width: 1px; height: 20px; background-color: var(--border-primary);"></div>
<button
<button
x-show="!readOnly"
@click="toggleZenMode()"
class="p-2 transition"
style="color: var(--text-secondary);"
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ bcrypt==4.1.2
itsdangerous==2.1.2
slowapi==0.1.9
colorama==0.4.6
pywin32==306
psutil==5.9.8
Loading