Complete TODO items: security, features, polish
Security: - Encrypt Gitea tokens at rest (AES-256-GCM with MH_SECRET) - Secure cookie flag when behind HTTPS (X-Forwarded-Proto) - Password complexity (min 8 chars) - TOTP: defer persist until verified (totp_pending column) - Audit log table + logging on login/rename/password change Features: - Rename files/folders (double-click in tree, /api/files/rename) - beforeunload warning for unsaved changes - Mobile hamburger menu - PWA icons (192px, 512px) - Max file size enforcement (10MB) - Shared file read access (cross-user with permission check) Polish: - Toast notifications replace all alert() calls - Keyboard shortcut help overlay (Ctrl+/) - File rename via double-click in FileTree
This commit is contained in:
@@ -1,21 +1,21 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
- [ ] Encrypt Gitea tokens at rest in SQLite (use app-level AES with MH_SECRET)
|
- [x] Encrypt Gitea tokens at rest in SQLite (use app-level AES with MH_SECRET)
|
||||||
- [ ] Add `Secure` flag to auth cookie when behind HTTPS (detect via X-Forwarded-Proto)
|
- [x] Add `Secure` flag to auth cookie when behind HTTPS (detect via X-Forwarded-Proto)
|
||||||
- [ ] Password complexity requirements (min length, etc.)
|
- [x] Password complexity requirements (min 8 chars)
|
||||||
- [ ] TOTP: don't persist secret until verified (currently saves on setup)
|
- [x] TOTP: don't persist secret until verified (uses totp_pending column)
|
||||||
- [ ] Audit log (who did what, when)
|
- [x] Audit log (who did what, when)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- [ ] Rename files/folders (currently only move)
|
- [x] Rename files/folders (double-click in tree)
|
||||||
- [ ] Image upload (drag-drop into editor, store in assets folder)
|
- [ ] Image upload (drag-drop into editor, store in assets folder)
|
||||||
- [ ] Browser `beforeunload` warning with unsaved changes
|
- [x] Browser `beforeunload` warning with unsaved changes
|
||||||
- [ ] Mobile hamburger menu to toggle sidebar
|
- [x] Mobile hamburger menu to toggle sidebar
|
||||||
- [ ] PWA icons (icon-192.png, icon-512.png)
|
- [x] PWA icons (icon-192.png, icon-512.png)
|
||||||
- [ ] Session expiry / logout button in UI
|
- [x] Session expiry / logout button in UI
|
||||||
- [ ] Max file size enforcement on upload
|
- [x] Max file size enforcement on upload (10MB)
|
||||||
- [ ] Shared file read access (cross-user file serving)
|
- [x] Shared file read access (cross-user file serving)
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
- [ ] End-to-end: WYSIWYG mode (Milkdown)
|
- [ ] End-to-end: WYSIWYG mode (Milkdown)
|
||||||
@@ -27,9 +27,9 @@
|
|||||||
- [ ] End-to-end: offline edit → reconnect sync
|
- [ ] End-to-end: offline edit → reconnect sync
|
||||||
|
|
||||||
## Polish
|
## Polish
|
||||||
- [ ] Error toasts instead of alert()
|
- [x] Error toasts instead of alert()
|
||||||
- [ ] Loading spinners on API calls
|
- [ ] Loading spinners on API calls
|
||||||
- [ ] Keyboard shortcut help overlay (Ctrl+?)
|
- [x] Keyboard shortcut help overlay (Ctrl+/)
|
||||||
- [ ] File rename inline in tree (double-click)
|
- [x] File rename inline in tree (double-click)
|
||||||
- [ ] Drag files to trash
|
- [ ] Drag files to trash
|
||||||
- [ ] Sort files (name, date, size)
|
- [ ] Sort files (name, date, size)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 546 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
+144
-13
@@ -1,6 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<!-- Toast notifications -->
|
||||||
|
<div class="toast-container">
|
||||||
|
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">{{ t.msg }}</div>
|
||||||
|
</div>
|
||||||
<div class="app" v-if="authenticated">
|
<div class="app" v-if="authenticated">
|
||||||
<aside class="sidebar">
|
<button class="hamburger" @click="sidebarOpen = !sidebarOpen">☰</button>
|
||||||
|
<aside class="sidebar" :class="{open: sidebarOpen}">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h2>MarkdownHub</h2>
|
<h2>MarkdownHub</h2>
|
||||||
<div class="sidebar-actions">
|
<div class="sidebar-actions">
|
||||||
@@ -21,7 +26,7 @@
|
|||||||
<button @click="logout" title="Logout">🚪</button>
|
<button @click="logout" title="Logout">🚪</button>
|
||||||
<button @click="view = 'about'" title="About">ℹ️</button>
|
<button @click="view = 'about'" title="About">ℹ️</button>
|
||||||
</div>
|
</div>
|
||||||
<FileTree v-if="view === 'files'" :files="filteredFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" @move="moveFile" />
|
<FileTree v-if="view === 'files'" :files="filteredFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" @move="moveFile" @rename="renameFile" />
|
||||||
<FileTree v-if="view === 'shared'" :files="sharedFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" @move="moveFile" />
|
<FileTree v-if="view === 'shared'" :files="sharedFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" @move="moveFile" />
|
||||||
<div v-if="view === 'trash'" class="trash-view">
|
<div v-if="view === 'trash'" class="trash-view">
|
||||||
<div class="trash-header">
|
<div class="trash-header">
|
||||||
@@ -250,6 +255,22 @@
|
|||||||
<p style="margin:16px 0"><a href="https://git.aholck.net/anders-pub/markdown-hub" target="_blank" rel="noopener">https://git.aholck.net/anders-pub/markdown-hub</a></p>
|
<p style="margin:16px 0"><a href="https://git.aholck.net/anders-pub/markdown-hub" target="_blank" rel="noopener">https://git.aholck.net/anders-pub/markdown-hub</a></p>
|
||||||
<p style="color:var(--text-muted)">Anders Holck 2026</p>
|
<p style="color:var(--text-muted)">Anders Holck 2026</p>
|
||||||
</main>
|
</main>
|
||||||
|
<!-- Shortcuts overlay -->
|
||||||
|
<div class="overlay" v-if="showShortcuts" @click="showShortcuts = false">
|
||||||
|
<div class="overlay-content" @click.stop>
|
||||||
|
<h3>Keyboard Shortcuts</h3>
|
||||||
|
<table><tbody>
|
||||||
|
<tr><td><kbd>Ctrl+S</kbd></td><td>Save</td></tr>
|
||||||
|
<tr><td><kbd>Ctrl+B</kbd></td><td>Bold</td></tr>
|
||||||
|
<tr><td><kbd>Ctrl+I</kbd></td><td>Italic</td></tr>
|
||||||
|
<tr><td><kbd>Ctrl+K</kbd></td><td>Link</td></tr>
|
||||||
|
<tr><td><kbd>Ctrl+`</kbd></td><td>Inline code</td></tr>
|
||||||
|
<tr><td><kbd>Tab</kbd></td><td>Indent</td></tr>
|
||||||
|
<tr><td><kbd>Ctrl+/</kbd></td><td>This help</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
<button @click="showShortcuts = false" style="margin-top:12px">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="login" v-else>
|
<div class="login" v-else>
|
||||||
<form @submit.prevent="login">
|
<form @submit.prevent="login">
|
||||||
@@ -300,6 +321,32 @@ const gitDirty = ref(0)
|
|||||||
const aiResult = ref('')
|
const aiResult = ref('')
|
||||||
const trashItems = ref([])
|
const trashItems = ref([])
|
||||||
const collabActive = ref(false)
|
const collabActive = ref(false)
|
||||||
|
const sidebarOpen = ref(false)
|
||||||
|
const toasts = ref([])
|
||||||
|
const showShortcuts = ref(false)
|
||||||
|
|
||||||
|
let toastId = 0
|
||||||
|
function toast(msg, type = 'info') {
|
||||||
|
const id = ++toastId
|
||||||
|
toasts.value.push({ id, msg, type })
|
||||||
|
setTimeout(() => { toasts.value = toasts.value.filter(t => t.id !== id) }, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// beforeunload warning
|
||||||
|
window.addEventListener('beforeunload', (e) => {
|
||||||
|
if (isDirty.value) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.returnValue = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Global shortcuts
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||||
|
e.preventDefault()
|
||||||
|
showShortcuts.value = !showShortcuts.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
const prefs = ref({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, defaultMode: 'split', theme: 'dark' })
|
const prefs = ref({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, defaultMode: 'split', theme: 'dark' })
|
||||||
@@ -424,7 +471,7 @@ async function openFile(path) {
|
|||||||
content.value = cached
|
content.value = cached
|
||||||
fileMeta.value = '(offline cache)'
|
fileMeta.value = '(offline cache)'
|
||||||
} else {
|
} else {
|
||||||
alert('File unavailable offline')
|
toast('File unavailable offline', 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -512,7 +559,22 @@ async function moveFile({ from, to }) {
|
|||||||
}
|
}
|
||||||
await loadFiles()
|
await loadFiles()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Move failed')
|
toast('Move failed', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameFile(item) {
|
||||||
|
const newName = prompt('New name:', item.name)
|
||||||
|
if (!newName || newName === item.name) return
|
||||||
|
try {
|
||||||
|
const res = await api('/api/files/rename', { path: item.path, new_name: newName })
|
||||||
|
if (currentFile.value === item.path) {
|
||||||
|
currentFile.value = res.new_path
|
||||||
|
}
|
||||||
|
await loadFiles()
|
||||||
|
toast('Renamed', 'success')
|
||||||
|
} catch (e) {
|
||||||
|
toast('Rename failed', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,7 +639,7 @@ async function setupTOTP() {
|
|||||||
totpUri.value = res.uri
|
totpUri.value = res.uri
|
||||||
totpSecret.value = res.secret
|
totpSecret.value = res.secret
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Failed to setup 2FA')
|
toast('Failed to setup 2FA', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,9 +650,9 @@ async function verifyTOTP() {
|
|||||||
totpUri.value = ''
|
totpUri.value = ''
|
||||||
totpSecret.value = ''
|
totpSecret.value = ''
|
||||||
totpCode.value = ''
|
totpCode.value = ''
|
||||||
alert('2FA enabled!')
|
toast('2FA enabled!', 'success')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Invalid code')
|
toast('Invalid code', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,9 +661,9 @@ async function disableTOTP() {
|
|||||||
await api('/api/auth/totp/disable', { code: totpCode.value })
|
await api('/api/auth/totp/disable', { code: totpCode.value })
|
||||||
totpEnabled.value = false
|
totpEnabled.value = false
|
||||||
totpCode.value = ''
|
totpCode.value = ''
|
||||||
alert('2FA disabled')
|
toast('2FA disabled', 'success')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Invalid code')
|
toast('Invalid code', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,21 +685,21 @@ async function addRemote() {
|
|||||||
async function gitPush() {
|
async function gitPush() {
|
||||||
try {
|
try {
|
||||||
await api('/api/git/push', { remote: gitRemotes.value[0]?.name || 'origin' })
|
await api('/api/git/push', { remote: gitRemotes.value[0]?.name || 'origin' })
|
||||||
alert('Pushed successfully')
|
toast('Pushed successfully', 'success')
|
||||||
checkGitStatus()
|
checkGitStatus()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Push failed')
|
toast('Push failed', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function gitPull() {
|
async function gitPull() {
|
||||||
try {
|
try {
|
||||||
await api('/api/git/pull', { remote: gitRemotes.value[0]?.name || 'origin' })
|
await api('/api/git/pull', { remote: gitRemotes.value[0]?.name || 'origin' })
|
||||||
alert('Pulled successfully')
|
toast('Pulled successfully', 'success')
|
||||||
loadFiles()
|
loadFiles()
|
||||||
checkGitStatus()
|
checkGitStatus()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Pull failed')
|
toast('Pull failed', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1326,9 +1388,78 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
|||||||
|
|
||||||
/* ─── Responsive ──────────────────────────────────────────────────────────── */
|
/* ─── Responsive ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.hamburger {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 20px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.hamburger { display: block; }
|
||||||
.sidebar { position: fixed; left: -260px; z-index: 10; transition: left 0.2s; }
|
.sidebar { position: fixed; left: -260px; z-index: 10; transition: left 0.2s; }
|
||||||
.sidebar.open { left: 0; }
|
.sidebar.open { left: 0; }
|
||||||
.format-toolbar { display: none; }
|
.format-toolbar { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Overlay ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9000;
|
||||||
|
}
|
||||||
|
.overlay-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
.overlay-content table { width: 100%; margin-top: 12px; }
|
||||||
|
.overlay-content td { padding: 4px 8px; font-size: 13px; }
|
||||||
|
.overlay-content kbd {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Toasts ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
animation: slideIn 0.2s ease;
|
||||||
|
}
|
||||||
|
.toast.success { background: #1a7f37; border-color: #2ea043; }
|
||||||
|
.toast.error { background: #cf222e; border-color: #f38ba8; }
|
||||||
|
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
class="tree-node"
|
class="tree-node"
|
||||||
:class="{ selected: item.path === selected, folder: item.isDir, 'drop-target': dropTarget === item.path }"
|
:class="{ selected: item.path === selected, folder: item.isDir, 'drop-target': dropTarget === item.path }"
|
||||||
@click="item.isDir ? toggleFolder(item) : $emit('select', item.path)"
|
@click="item.isDir ? toggleFolder(item) : $emit('select', item.path)"
|
||||||
@contextmenu.prevent="null"
|
@dblclick.stop="$emit('rename', item)"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="onDragStart($event, item)"
|
@dragstart="onDragStart($event, item)"
|
||||||
@dragover.prevent="onDragOver($event, item)"
|
@dragover.prevent="onDragOver($event, item)"
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<button class="delete-btn" @click.stop="$emit('delete', item)" title="Delete">×</button>
|
<button class="delete-btn" @click.stop="$emit('delete', item)" title="Delete">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.isDir && expanded[item.path] && item.children" class="children">
|
<div v-if="item.isDir && expanded[item.path] && item.children" class="children">
|
||||||
<FileTree :files="item.children" :selected="selected" @select="$emit('select', $event)" @delete="$emit('delete', $event)" @move="$emit('move', $event)" />
|
<FileTree :files="item.children" :selected="selected" @select="$emit('select', $event)" @delete="$emit('delete', $event)" @move="$emit('move', $event)" @rename="$emit('rename', $event)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +31,7 @@ defineProps({
|
|||||||
selected: { type: String, default: '' },
|
selected: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['select', 'delete', 'move'])
|
const emit = defineEmits(['select', 'delete', 'move', 'rename'])
|
||||||
|
|
||||||
const expanded = reactive({})
|
const expanded = reactive({})
|
||||||
const dropTarget = ref('')
|
const dropTarget = ref('')
|
||||||
@@ -60,7 +60,6 @@ function onDrop(e, targetItem) {
|
|||||||
dropTarget.value = ''
|
dropTarget.value = ''
|
||||||
const sourcePath = e.dataTransfer.getData('text/plain')
|
const sourcePath = e.dataTransfer.getData('text/plain')
|
||||||
if (!sourcePath || !targetItem.isDir || sourcePath === targetItem.path) return
|
if (!sourcePath || !targetItem.isDir || sourcePath === targetItem.path) return
|
||||||
// Don't drop into itself
|
|
||||||
if (targetItem.path.startsWith(sourcePath + '/')) return
|
if (targetItem.path.startsWith(sourcePath + '/')) return
|
||||||
emit('move', { from: sourcePath, to: targetItem.path })
|
emit('move', { from: sourcePath, to: targetItem.path })
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-1
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"markdownhub/internal/crypto"
|
||||||
"markdownhub/internal/files"
|
"markdownhub/internal/files"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,10 +40,18 @@ func (s *Server) handleBuildSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
jobID := uuid.New().String()
|
jobID := uuid.New().String()
|
||||||
|
// Encrypt Gitea token at rest
|
||||||
|
encToken := req.GiteaToken
|
||||||
|
if encToken != "" {
|
||||||
|
key := crypto.KeyFromSecret(s.secret)
|
||||||
|
if enc, err := crypto.EncryptString(encToken, key); err == nil {
|
||||||
|
encToken = enc
|
||||||
|
}
|
||||||
|
}
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO build_jobs (id, user_id, status, spec_content, gitea_url, gitea_token, gitea_org, repo_name, model)
|
`INSERT INTO build_jobs (id, user_id, status, spec_content, gitea_url, gitea_token, gitea_org, repo_name, model)
|
||||||
VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?)`,
|
||||||
jobID, userID, specContent, req.GiteaURL, req.GiteaToken, req.GiteaOrg, req.RepoName, req.Model,
|
jobID, userID, specContent, req.GiteaURL, encToken, req.GiteaOrg, req.RepoName, req.Model,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, 500, map[string]string{"error": "failed to create job"})
|
writeJSON(w, 500, map[string]string{"error": "failed to create job"})
|
||||||
@@ -133,6 +142,14 @@ func (s *Server) handleDaemonPoll(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Mark as picked up
|
// Mark as picked up
|
||||||
s.db.Exec("UPDATE build_jobs SET status = 'running', updated_at = datetime('now') WHERE id = ?", id)
|
s.db.Exec("UPDATE build_jobs SET status = 'running', updated_at = datetime('now') WHERE id = ?", id)
|
||||||
|
|
||||||
|
// Decrypt token
|
||||||
|
if giteaToken != "" {
|
||||||
|
key := crypto.KeyFromSecret(s.secret)
|
||||||
|
if dec, err := crypto.DecryptString(giteaToken, key); err == nil {
|
||||||
|
giteaToken = dec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, 200, map[string]interface{}{
|
writeJSON(w, 200, map[string]interface{}{
|
||||||
"job_id": id,
|
"job_id": id,
|
||||||
"spec_content": specContent,
|
"spec_content": specContent,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -56,6 +57,13 @@ func recordLoginAttempt(ip string) {
|
|||||||
loginAttempts.m[ip] = append(loginAttempts.m[ip], time.Now())
|
loginAttempts.m[ip] = append(loginAttempts.m[ip], time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validatePassword(pw string) string {
|
||||||
|
if len(pw) < 8 {
|
||||||
|
return "password must be at least 8 characters"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -110,6 +118,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
Value: token,
|
Value: token,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: r.Header.Get("X-Forwarded-Proto") == "https",
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
Expires: time.Now().Add(72 * time.Hour),
|
Expires: time.Now().Add(72 * time.Hour),
|
||||||
})
|
})
|
||||||
@@ -119,6 +128,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
"userId": id,
|
"userId": id,
|
||||||
"isAdmin": isAdmin,
|
"isAdmin": isAdmin,
|
||||||
})
|
})
|
||||||
|
s.audit(id, "login", req.Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -141,6 +151,10 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 400, map[string]string{"error": "current_password and new_password required"})
|
writeJSON(w, 400, map[string]string{"error": "current_password and new_password required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg := validatePassword(req.NewPassword); msg != "" {
|
||||||
|
writeJSON(w, 400, map[string]string{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
userID := getUserID(r)
|
userID := getUserID(r)
|
||||||
var hash string
|
var hash string
|
||||||
@@ -157,6 +171,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.db.Exec("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?", newHash, userID)
|
s.db.Exec("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?", newHash, userID)
|
||||||
|
s.audit(userID, "change_password", "")
|
||||||
writeJSON(w, 200, map[string]string{"status": "password changed"})
|
writeJSON(w, 200, map[string]string{"status": "password changed"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +188,10 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 400, map[string]string{"error": "username, email, and password required"})
|
writeJSON(w, 400, map[string]string{"error": "username, email, and password required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg := validatePassword(req.Password); msg != "" {
|
||||||
|
writeJSON(w, 400, map[string]string{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
hash, err := auth.HashPassword(req.Password)
|
hash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -237,7 +256,8 @@ func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleReadFile(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleReadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
OwnerID string `json:"owner_id"`
|
||||||
}
|
}
|
||||||
if err := decodeBody(r, &req); err != nil || req.Path == "" {
|
if err := decodeBody(r, &req); err != nil || req.Path == "" {
|
||||||
writeJSON(w, 400, map[string]string{"error": "path required"})
|
writeJSON(w, 400, map[string]string{"error": "path required"})
|
||||||
@@ -245,12 +265,29 @@ func (s *Server) handleReadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userID := getUserID(r)
|
userID := getUserID(r)
|
||||||
content, err := files.ReadFile(s.dataDir, userID, req.Path)
|
readFrom := userID
|
||||||
|
|
||||||
|
// If owner_id specified, check permissions (shared file access)
|
||||||
|
if req.OwnerID != "" && req.OwnerID != userID {
|
||||||
|
var level string
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
SELECT p.level FROM permissions p
|
||||||
|
JOIN files f ON f.id = p.file_id
|
||||||
|
WHERE f.owner_id = ? AND f.path = ? AND p.user_id = ?`,
|
||||||
|
req.OwnerID, req.Path, userID).Scan(&level)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, 403, map[string]string{"error": "access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
readFrom = req.OwnerID
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := files.ReadFile(s.dataDir, readFrom, req.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, 404, map[string]string{"error": "file not found"})
|
writeJSON(w, 404, map[string]string{"error": "file not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
created := files.GetCreatedTime(s.dataDir, userID, req.Path)
|
created := files.GetCreatedTime(s.dataDir, readFrom, req.Path)
|
||||||
writeJSON(w, 200, map[string]interface{}{"path": req.Path, "content": content, "created": created})
|
writeJSON(w, 200, map[string]interface{}{"path": req.Path, "content": content, "created": created})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +300,10 @@ func (s *Server) handleWriteFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 400, map[string]string{"error": "path required"})
|
writeJSON(w, 400, map[string]string{"error": "path required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(req.Content) > maxBodySize {
|
||||||
|
writeJSON(w, 413, map[string]string{"error": "file too large (max 10MB)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
userID := getUserID(r)
|
userID := getUserID(r)
|
||||||
if err := files.WriteFile(s.dataDir, userID, req.Path, req.Content); err != nil {
|
if err := files.WriteFile(s.dataDir, userID, req.Path, req.Content); err != nil {
|
||||||
@@ -349,6 +390,27 @@ func (s *Server) handleMoveFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 200, map[string]string{"status": "moved"})
|
writeJSON(w, 200, map[string]string{"status": "moved"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRenameFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
NewName string `json:"new_name"`
|
||||||
|
}
|
||||||
|
if err := decodeBody(r, &req); err != nil || req.Path == "" || req.NewName == "" {
|
||||||
|
writeJSON(w, 400, map[string]string{"error": "path and new_name required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getUserID(r)
|
||||||
|
dir := filepath.Dir(req.Path)
|
||||||
|
newPath := filepath.Join(dir, req.NewName)
|
||||||
|
if err := files.MoveFile(s.dataDir, userID, req.Path, newPath); err != nil {
|
||||||
|
writeJSON(w, 500, map[string]string{"error": "rename failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.audit(userID, "rename", req.Path+" -> "+newPath)
|
||||||
|
writeJSON(w, 200, map[string]string{"status": "renamed", "new_path": newPath})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleSharedFiles(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSharedFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: query permissions table for files shared with this user
|
// TODO: query permissions table for files shared with this user
|
||||||
// For now return empty list
|
// For now return empty list
|
||||||
|
|||||||
@@ -61,3 +61,7 @@ func getUserID(r *http.Request) string {
|
|||||||
v, _ := r.Context().Value(ctxUserID).(string)
|
v, _ := r.Context().Value(ctxUserID).(string)
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) audit(userID, action, detail string) {
|
||||||
|
s.db.Exec("INSERT INTO audit_log (user_id, action, detail) VALUES (?, ?, ?)", userID, action, detail)
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func NewRouter(db *sql.DB, dataDir, secret string) http.Handler {
|
|||||||
mux.HandleFunc("POST /api/files/create-folder", s.requireAuth(s.handleCreateFolder))
|
mux.HandleFunc("POST /api/files/create-folder", s.requireAuth(s.handleCreateFolder))
|
||||||
mux.HandleFunc("POST /api/files/delete", s.requireAuth(s.handleDeleteFile))
|
mux.HandleFunc("POST /api/files/delete", s.requireAuth(s.handleDeleteFile))
|
||||||
mux.HandleFunc("POST /api/files/move", s.requireAuth(s.handleMoveFile))
|
mux.HandleFunc("POST /api/files/move", s.requireAuth(s.handleMoveFile))
|
||||||
|
mux.HandleFunc("POST /api/files/rename", s.requireAuth(s.handleRenameFile))
|
||||||
mux.HandleFunc("POST /api/files/trash", s.requireAuth(s.handleListTrash))
|
mux.HandleFunc("POST /api/files/trash", s.requireAuth(s.handleListTrash))
|
||||||
mux.HandleFunc("POST /api/files/trash/restore", s.requireAuth(s.handleRestoreTrash))
|
mux.HandleFunc("POST /api/files/trash/restore", s.requireAuth(s.handleRestoreTrash))
|
||||||
mux.HandleFunc("POST /api/files/trash/empty", s.requireAuth(s.handleEmptyTrash))
|
mux.HandleFunc("POST /api/files/trash/empty", s.requireAuth(s.handleEmptyTrash))
|
||||||
|
|||||||
+16
-12
@@ -9,17 +9,15 @@ import (
|
|||||||
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := getUserID(r)
|
userID := getUserID(r)
|
||||||
|
|
||||||
// Generate secret
|
|
||||||
secret := auth.GenerateTOTPSecret()
|
secret := auth.GenerateTOTPSecret()
|
||||||
|
|
||||||
// Get user email for URI
|
|
||||||
var email string
|
var email string
|
||||||
s.db.QueryRow("SELECT email FROM users WHERE id = ?", userID).Scan(&email)
|
s.db.QueryRow("SELECT email FROM users WHERE id = ?", userID).Scan(&email)
|
||||||
|
|
||||||
uri := auth.TOTPUri(secret, email, "MarkdownHub")
|
uri := auth.TOTPUri(secret, email, "MarkdownHub")
|
||||||
|
|
||||||
// Store secret (not yet verified)
|
// Store in pending column — not active until verified
|
||||||
s.db.Exec("UPDATE users SET totp_secret = ? WHERE id = ?", secret, userID)
|
s.db.Exec("UPDATE users SET totp_pending = ? WHERE id = ?", secret, userID)
|
||||||
|
|
||||||
writeJSON(w, 200, map[string]string{
|
writeJSON(w, 200, map[string]string{
|
||||||
"secret": secret,
|
"secret": secret,
|
||||||
@@ -37,18 +35,20 @@ func (s *Server) handleTOTPVerify(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userID := getUserID(r)
|
userID := getUserID(r)
|
||||||
var secret string
|
var pending *string
|
||||||
s.db.QueryRow("SELECT totp_secret FROM users WHERE id = ?", userID).Scan(&secret)
|
s.db.QueryRow("SELECT totp_pending FROM users WHERE id = ?", userID).Scan(&pending)
|
||||||
if secret == "" {
|
if pending == nil || *pending == "" {
|
||||||
writeJSON(w, 400, map[string]string{"error": "TOTP not set up"})
|
writeJSON(w, 400, map[string]string{"error": "TOTP not set up — run setup first"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !auth.ValidateTOTP(secret, req.Code) {
|
if !auth.ValidateTOTP(*pending, req.Code) {
|
||||||
writeJSON(w, 401, map[string]string{"error": "invalid code"})
|
writeJSON(w, 401, map[string]string{"error": "invalid code"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Code valid — promote pending to active
|
||||||
|
s.db.Exec("UPDATE users SET totp_secret = ?, totp_pending = NULL WHERE id = ?", *pending, userID)
|
||||||
writeJSON(w, 200, map[string]string{"status": "verified"})
|
writeJSON(w, 200, map[string]string{"status": "verified"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,14 +62,18 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userID := getUserID(r)
|
userID := getUserID(r)
|
||||||
var secret string
|
var secret *string
|
||||||
s.db.QueryRow("SELECT totp_secret FROM users WHERE id = ?", userID).Scan(&secret)
|
s.db.QueryRow("SELECT totp_secret FROM users WHERE id = ?", userID).Scan(&secret)
|
||||||
|
if secret == nil || *secret == "" {
|
||||||
|
writeJSON(w, 400, map[string]string{"error": "2FA not enabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !auth.ValidateTOTP(secret, req.Code) {
|
if !auth.ValidateTOTP(*secret, req.Code) {
|
||||||
writeJSON(w, 401, map[string]string{"error": "invalid code"})
|
writeJSON(w, 401, map[string]string{"error": "invalid code"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.db.Exec("UPDATE users SET totp_secret = NULL WHERE id = ?", userID)
|
s.db.Exec("UPDATE users SET totp_secret = NULL, totp_pending = NULL WHERE id = ?", userID)
|
||||||
writeJSON(w, 200, map[string]string{"status": "disabled"})
|
writeJSON(w, 200, map[string]string{"status": "disabled"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"crypto/aes"
|
"crypto/aes"
|
||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
@@ -15,6 +17,34 @@ func DeriveKey(password []byte, salt []byte) []byte {
|
|||||||
return argon2.IDKey(password, salt, 3, 64*1024, 4, 32)
|
return argon2.IDKey(password, salt, 3, 64*1024, 4, 32)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KeyFromSecret derives a 256-bit AES key from a string secret (for token encryption).
|
||||||
|
func KeyFromSecret(secret string) []byte {
|
||||||
|
h := sha256.Sum256([]byte(secret))
|
||||||
|
return h[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptString encrypts a string and returns base64-encoded ciphertext.
|
||||||
|
func EncryptString(plaintext string, key []byte) (string, error) {
|
||||||
|
ct, err := Encrypt([]byte(plaintext), key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(ct), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptString decrypts a base64-encoded ciphertext to a string.
|
||||||
|
func DecryptString(encoded string, key []byte) (string, error) {
|
||||||
|
ct, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
pt, err := Decrypt(ct, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(pt), nil
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateSalt returns a random 16-byte salt.
|
// GenerateSalt returns a random 16-byte salt.
|
||||||
func GenerateSalt() ([]byte, error) {
|
func GenerateSalt() ([]byte, error) {
|
||||||
salt := make([]byte, 16)
|
salt := make([]byte, 16)
|
||||||
|
|||||||
+14
-1
@@ -30,7 +30,20 @@ func Open(path string) (*DB, error) {
|
|||||||
|
|
||||||
func Migrate(database *DB) error {
|
func Migrate(database *DB) error {
|
||||||
_, err := database.Exec(schema)
|
_, err := database.Exec(schema)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Add columns that may not exist yet (idempotent)
|
||||||
|
database.Exec("ALTER TABLE users ADD COLUMN totp_pending TEXT")
|
||||||
|
database.Exec("ALTER TABLE users ADD COLUMN audit_log_enabled INTEGER DEFAULT 0")
|
||||||
|
database.Exec(`CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)`)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var schema = `
|
var schema = `
|
||||||
|
|||||||
Reference in New Issue
Block a user