diff --git a/backend/Dockerfile b/backend/Dockerfile index 61aa0ac..363b076 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -32,6 +32,9 @@ COPY --from=builder /app/main . COPY --from=builder /app/api/internal/database/dev.sqlite3 ./api/internal/database/ COPY --from=builder /app/api/internal/database/create_tables.sql ./api/internal/database/ +# Copy admin UI static files +COPY --from=builder /app/api/admin ./admin + # Copy the uploads directory # Note: This assumes your application needs access to this directory. # If it's for temporary uploads, you might want to use a Docker volume instead. diff --git a/backend/api/admin/console.html b/backend/api/admin/console.html new file mode 100644 index 0000000..432c821 --- /dev/null +++ b/backend/api/admin/console.html @@ -0,0 +1,296 @@ + + + + + + DevBits Admin Console + + + +
+
+
+
DevBits Admin Console
+
+ Moderation panel with ultimate key or delegated admin login. +
+
+
+ + +
+
+
+ +
+
+
Users
+
-
+
+
+
Posts
+
-
+
+
+
Streams
+
-
+
+
+
Comments
+
-
+
+
+
+ +
+

Users

+
+ + + +
+
+
+ +
+

Posts (Bytes)

+
+ + + +
+
+
+
+ +
+

Projects (Streams)

+
+ + + +
+
+
+
+ +
+

Comments

+
+ + + +
+
+
+
+
+ + + + diff --git a/backend/api/admin/index.html b/backend/api/admin/index.html new file mode 100644 index 0000000..3e7440b --- /dev/null +++ b/backend/api/admin/index.html @@ -0,0 +1,108 @@ + + + + + + DevBits Admin Sign In + + + +
+

DevBits

+

+ Sign in as Administrator (password = ultimate key) or with an admin user + account. +

+ + + +
+
+ + + diff --git a/backend/api/admin/static/admin.js b/backend/api/admin/static/admin.js new file mode 100644 index 0000000..aaade56 --- /dev/null +++ b/backend/api/admin/static/admin.js @@ -0,0 +1,414 @@ +function getStoredAdminKey() { + return sessionStorage.getItem('devbits_admin_key') || '' +} + +function getStoredAdminToken() { + return sessionStorage.getItem('devbits_admin_token') || '' +} + +function setStoredAdminKey(value) { + const trimmed = (value || '').trim() + if (!trimmed) { + sessionStorage.removeItem('devbits_admin_key') + return '' + } + sessionStorage.setItem('devbits_admin_key', trimmed) + return trimmed +} + +function setStoredAdminToken(value) { + const trimmed = (value || '').trim() + if (!trimmed) { + sessionStorage.removeItem('devbits_admin_token') + return '' + } + sessionStorage.setItem('devbits_admin_token', trimmed) + return trimmed +} + +async function api(path, method = 'GET', body = null) { + const key = getStoredAdminKey() + const token = getStoredAdminToken() + if (!key && !token) { + window.location.href = '/admin' + throw new Error('admin authentication required') + } + + const opts = { method, headers: {} } + if (key) { + opts.headers['X-Admin-Key'] = key + } else if (token) { + opts.headers.Authorization = `Bearer ${token}` + } + + if (body) { + opts.body = JSON.stringify(body) + opts.headers['Content-Type'] = 'application/json' + } + + const res = await fetch(path, opts) + if (!res.ok) { + const text = await res.text() + throw new Error(`${res.status} ${res.statusText}: ${text}`) + } + return res.json().catch(() => null) +} + +function setText(id, value) { + const node = document.getElementById(id) + if (node) node.textContent = value +} + +function toText(value) { + if (value === null || typeof value === 'undefined') return '' + return String(value) +} + +function asArray(value) { + return Array.isArray(value) ? value : [] +} + +function renderTable(containerId, columns, rows, getActions) { + const container = document.getElementById(containerId) + if (!container) return + + const table = document.createElement('table') + const thead = document.createElement('thead') + const headRow = document.createElement('tr') + + columns.forEach(col => { + const th = document.createElement('th') + th.textContent = col.label + headRow.appendChild(th) + }) + + const actionTh = document.createElement('th') + actionTh.textContent = 'Actions' + headRow.appendChild(actionTh) + thead.appendChild(headRow) + table.appendChild(thead) + + const tbody = document.createElement('tbody') + rows.forEach(row => { + const tr = document.createElement('tr') + + columns.forEach(col => { + const td = document.createElement('td') + const rawValue = typeof col.value === 'function' ? col.value(row) : row[col.value] + td.textContent = toText(rawValue) + if (col.truncate) td.className = 'cell-truncate' + tr.appendChild(td) + }) + + const actionTd = document.createElement('td') + const actions = typeof getActions === 'function' ? getActions(row) : [] + actions.forEach((action, index) => { + const button = document.createElement('button') + button.textContent = action.label + button.className = action.className || '' + button.onclick = action.onClick + actionTd.appendChild(button) + if (index < actions.length - 1) { + const spacer = document.createElement('span') + spacer.textContent = ' ' + actionTd.appendChild(spacer) + } + }) + tr.appendChild(actionTd) + + tbody.appendChild(tr) + }) + + table.appendChild(tbody) + container.innerHTML = '' + container.appendChild(table) +} + +async function refreshOverview() { + try { + const data = await api('/admin/overview') + setText('stat-users', data?.counts?.users ?? '-') + setText('stat-posts', data?.counts?.posts ?? '-') + setText('stat-projects', data?.counts?.projects ?? '-') + setText('stat-comments', data?.counts?.comments ?? '-') + setText('overview-status', `Server: ${data?.server_time ?? '-'} DB: ${data?.db_time ?? 'n/a'}`) + } catch (error) { + setText('overview-status', `Overview error: ${error.message}`) + } +} + +async function updateAuthStatus() { + const hasKey = !!getStoredAdminKey() + const hasToken = !!getStoredAdminToken() + if (!hasKey && !hasToken) { + setText('auth-status', 'Not signed in.') + return + } + + try { + const me = await api('/admin/me') + if (me?.mode === 'key') { + setText('auth-status', 'Signed in with ultimate admin key.') + return + } + const label = me?.username ? `Signed in as admin user: ${me.username}.` : 'Signed in with admin account.' + setText('auth-status', label) + } catch (error) { + setText('auth-status', `Auth status error: ${error.message}`) + } +} + +async function refreshUsers() { + try { + const q = document.getElementById('user-search').value || '' + const path = q ? `/admin/users?q=${encodeURIComponent(q)}` : '/admin/users' + const usersResponse = asArray(await api(path)) + const users = Array.isArray(usersResponse) ? usersResponse : [] + renderTable( + 'users', + [ + { label: 'ID', value: 'id' }, + { label: 'Username', value: 'username' }, + { label: 'Admin', value: (u) => (u.is_admin ? 'Yes' : 'No') }, + { label: 'Ban Until', value: (u) => u.ban_until || '' }, + { label: 'Ban Reason', value: (u) => u.ban_reason || '', truncate: true }, + { label: 'Bio', value: (u) => u.bio || '', truncate: true }, + { label: 'Created', value: 'creation_date' }, + ], + users, + (u) => { + const actions = [] + + actions.push({ + label: u.is_admin ? 'Remove Admin' : 'Make Admin', + className: 'secondary', + onClick: async () => { + try { + const res = await api(`/admin/users/${encodeURIComponent(u.username)}/admin`, 'POST', { + is_admin: !u.is_admin, + }) + alert(res.message || 'Admin status updated') + await refreshUsers() + } catch (e) { + alert('Error: ' + e.message) + } + } + }) + + if (u.ban_until) { + actions.push({ + label: 'Unban', + className: 'secondary', + onClick: async () => { + try { + const res = await api(`/admin/users/${encodeURIComponent(u.username)}/unban`, 'POST') + alert(res.message || 'User unbanned') + await refreshUsers() + } catch (e) { + alert('Error: ' + e.message) + } + } + }) + } else { + actions.push({ + label: 'Ban', + className: 'secondary', + onClick: async () => { + const reason = (prompt(`Ban reason for ${u.username}:`, 'Violation of community guidelines') || '').trim() + if (!reason) { + alert('Ban reason is required.') + return + } + + const minutesText = (prompt('Ban duration (minutes):', '60') || '').trim() + const durationMinutes = Number.parseInt(minutesText, 10) + if (!Number.isFinite(durationMinutes) || durationMinutes <= 0) { + alert('Duration must be a positive whole number of minutes.') + return + } + + try { + const res = await api(`/admin/users/${encodeURIComponent(u.username)}/ban`, 'POST', { + reason, + duration_minutes: durationMinutes, + }) + alert(res.message || 'User banned') + await refreshUsers() + } catch (e) { + alert('Error: ' + e.message) + } + } + }) + } + + actions.push({ + label: 'Delete', + className: 'danger', + onClick: async () => { + if (!confirm('Delete user ' + u.username + '?')) return + try { + const res = await api(`/admin/users/${encodeURIComponent(u.username)}`, 'DELETE') + alert(res.message || 'deleted') + await refreshUsers() + await refreshOverview() + } catch (e) { + alert('Error: ' + e.message) + } + } + }) + + return actions + } + ) + setText('overview-status', `Loaded ${users.length} user row(s)`) + } catch (e) { + alert('Error: ' + e.message) + } +} + +document.getElementById('refresh-users').onclick = refreshUsers +document.getElementById('clear-search').onclick = () => { document.getElementById('user-search').value = ''; refreshUsers() } +document.getElementById('refresh-overview').onclick = refreshOverview +document.getElementById('reset-key').onclick = () => { + setStoredAdminKey('') + setStoredAdminToken('') + window.location.href = '/admin' +} + +async function searchPosts() { + const q = document.getElementById('post-search').value || '' + try { + const path = q ? `/admin/posts?q=${encodeURIComponent(q)}` : '/admin/posts' + const postsResponse = asArray(await api(path)) + const posts = Array.isArray(postsResponse) ? postsResponse : [] + renderTable( + 'posts', + [ + { label: 'ID', value: 'id' }, + { label: 'User ID', value: 'user' }, + { label: 'Stream ID', value: 'project' }, + { label: 'Content', value: (p) => p.content || '', truncate: true }, + { label: 'Created', value: 'creation_date' }, + ], + posts, + (p) => ([{ + label: 'Delete', + className: 'danger', + onClick: async () => { + if (!confirm('Delete post ' + p.id + '?')) return + try { + const res = await api(`/admin/posts/${encodeURIComponent(p.id)}`, 'DELETE') + setText('post-result', res.message || 'deleted') + await searchPosts() + await refreshOverview() + } catch (e) { + alert('Error: ' + e.message) + } + } + }]) + ) + setText('post-result', `${posts.length} result(s)`) + } catch (e) { + setText('post-result', 'Error: ' + e.message) + } +} + +document.getElementById('search-posts').onclick = searchPosts +document.getElementById('clear-post-search').onclick = () => { document.getElementById('post-search').value = ''; searchPosts() } + +async function searchProjects() { + const q = document.getElementById('project-search').value || '' + try { + const path = q ? `/admin/projects?q=${encodeURIComponent(q)}` : '/admin/projects' + const projectsResponse = asArray(await api(path)) + const projects = Array.isArray(projectsResponse) ? projectsResponse : [] + renderTable( + 'projects', + [ + { label: 'ID', value: 'id' }, + { label: 'Owner ID', value: 'owner' }, + { label: 'Name', value: (p) => p.name || '' }, + { label: 'Description', value: (p) => p.description || '', truncate: true }, + { label: 'Created', value: 'creation_date' }, + ], + projects, + (p) => ([{ + label: 'Delete', + className: 'danger', + onClick: async () => { + if (!confirm('Delete project ' + p.id + '?')) return + try { + const res = await api(`/admin/projects/${encodeURIComponent(p.id)}`, 'DELETE') + setText('project-result', res.message || 'deleted') + await searchProjects() + await refreshOverview() + } catch (e) { + alert('Error: ' + e.message) + } + } + }]) + ) + setText('project-result', `${projects.length} result(s)`) + } catch (e) { + setText('project-result', 'Error: ' + e.message) + } +} + +document.getElementById('search-projects').onclick = searchProjects +document.getElementById('clear-project-search').onclick = () => { document.getElementById('project-search').value = ''; searchProjects() } + +async function searchComments() { + const q = document.getElementById('comment-search').value || '' + try { + const path = q ? `/admin/comments?q=${encodeURIComponent(q)}` : '/admin/comments' + const commentsResponse = asArray(await api(path)) + const comments = Array.isArray(commentsResponse) ? commentsResponse : [] + renderTable( + 'comments', + [ + { label: 'ID', value: 'id' }, + { label: 'User ID', value: 'user' }, + { label: 'Content', value: (c) => c.content || '', truncate: true }, + { label: 'Created', value: 'created_on' }, + ], + comments, + (c) => ([{ + label: 'Delete', + className: 'danger', + onClick: async () => { + if (!confirm('Delete comment ' + c.id + '?')) return + try { + const res = await api(`/admin/comments/${encodeURIComponent(c.id)}`, 'DELETE') + setText('comment-result', res.message || 'deleted') + await searchComments() + await refreshOverview() + } catch (e) { + setText('comment-result', 'Error: ' + e.message) + } + } + }]) + ) + setText('comment-result', `${comments.length} result(s)`) + } catch (e) { + setText('comment-result', 'Error: ' + e.message) + } +} + +document.getElementById('search-comments').onclick = searchComments +document.getElementById('clear-comment-search').onclick = () => { document.getElementById('comment-search').value = ''; searchComments() } + +window.addEventListener('load', async () => { + const existingKey = getStoredAdminKey() + const existingToken = getStoredAdminToken() + if (!existingKey && !existingToken) { + window.location.href = '/admin' + return + } + + await updateAuthStatus() + await refreshOverview() + await refreshUsers() + await searchPosts() + await searchProjects() + await searchComments() +}) diff --git a/backend/api/admin/static/login.js b/backend/api/admin/static/login.js new file mode 100644 index 0000000..cc0abc3 --- /dev/null +++ b/backend/api/admin/static/login.js @@ -0,0 +1,123 @@ +function setStoredAdminKey(value) { + const trimmed = (value || '').trim() + if (!trimmed) { + sessionStorage.removeItem('devbits_admin_key') + return '' + } + sessionStorage.setItem('devbits_admin_key', trimmed) + return trimmed +} + +function setStoredAdminToken(value) { + const trimmed = (value || '').trim() + if (!trimmed) { + sessionStorage.removeItem('devbits_admin_token') + return '' + } + sessionStorage.setItem('devbits_admin_token', trimmed) + return trimmed +} + +async function verifyKey(key) { + const res = await fetch('/admin/overview', { + method: 'GET', + headers: { 'X-Admin-Key': key } + }) + return res.ok +} + +async function loginWithCredentials(username, password) { + const res = await fetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }) + + let payload = null + try { + payload = await res.json() + } catch (_) { + payload = null + } + + if (!res.ok) { + const msg = payload?.message || payload?.error || 'Invalid credentials' + throw new Error(msg) + } + + const token = payload?.token + if (!token) { + throw new Error('Login succeeded but token is missing') + } + + const meRes = await fetch('/admin/me', { + method: 'GET', + headers: { Authorization: `Bearer ${token}` } + }) + + if (!meRes.ok) { + if (meRes.status === 403) { + throw new Error('This account is not an admin') + } + throw new Error('Failed to verify admin access') + } + + return token +} + +const statusEl = document.getElementById('status') +const usernameInput = document.getElementById('username-input') +const passwordInput = document.getElementById('password-input') +const signInBtn = document.getElementById('sign-in') + +async function signIn() { + const username = (usernameInput.value || '').trim() + const password = (passwordInput.value || '').trim() + + if (!username || !password) { + statusEl.textContent = 'Enter username and password.' + return + } + + statusEl.textContent = 'Signing in...' + signInBtn.disabled = true + try { + if (username.toLowerCase() === 'administrator') { + const ok = await verifyKey(password) + if (!ok) { + statusEl.textContent = 'Invalid Administrator password.' + return + } + + setStoredAdminToken('') + setStoredAdminKey(password) + } else { + const token = await loginWithCredentials(username, password) + setStoredAdminKey('') + setStoredAdminToken(token) + } + + statusEl.textContent = 'Success. Redirecting...' + window.location.href = '/admin/console' + } catch (error) { + statusEl.textContent = `Sign in failed: ${error.message || 'network error'}` + } finally { + signInBtn.disabled = false + } +} + +signInBtn.addEventListener('click', signIn) + +usernameInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault() + signIn() + } +}) + +passwordInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault() + signIn() + } +}) diff --git a/backend/api/devbits-api b/backend/api/devbits-api new file mode 100644 index 0000000..3799819 Binary files /dev/null and b/backend/api/devbits-api differ diff --git a/backend/api/internal/database/admin_queries.go b/backend/api/internal/database/admin_queries.go new file mode 100644 index 0000000..8a17c14 --- /dev/null +++ b/backend/api/internal/database/admin_queries.go @@ -0,0 +1,104 @@ +package database + +import ( + "database/sql" + "fmt" + "time" +) + +type ActiveBan struct { + UserID int `json:"user_id"` + Reason string `json:"reason"` + BannedUntil time.Time `json:"banned_until"` + CreatedAt time.Time `json:"created_at"` +} + +func IsUserAdmin(userID int64) (bool, error) { + var exists bool + query := `SELECT EXISTS(SELECT 1 FROM adminusers WHERE user_id = $1)` + if err := DB.QueryRow(query, userID).Scan(&exists); err != nil { + return false, err + } + return exists, nil +} + +func SetUserAdmin(userID int64, grantedBy *int64, isAdmin bool) error { + if isAdmin { + query := `INSERT INTO adminusers (user_id, granted_at, granted_by) + VALUES ($1, $2, $3) + ON CONFLICT (user_id) DO UPDATE SET granted_at = EXCLUDED.granted_at, granted_by = EXCLUDED.granted_by` + _, err := DB.Exec(query, userID, time.Now().UTC(), grantedBy) + return err + } + + _, err := DB.Exec(`DELETE FROM adminusers WHERE user_id = $1`, userID) + return err +} + +func GetActiveBanByUserID(userID int64) (*ActiveBan, error) { + query := `SELECT user_id, reason, banned_until, created_at + FROM userbans + WHERE user_id = $1 AND lifted_at IS NULL AND banned_until > CURRENT_TIMESTAMP + ORDER BY banned_until DESC + LIMIT 1` + + ban := &ActiveBan{} + err := DB.QueryRow(query, userID).Scan(&ban.UserID, &ban.Reason, &ban.BannedUntil, &ban.CreatedAt) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return ban, nil +} + +func GetActiveBanByUsername(username string) (*ActiveBan, error) { + query := `SELECT ub.user_id, ub.reason, ub.banned_until, ub.created_at + FROM userbans ub + JOIN users u ON u.id = ub.user_id + WHERE LOWER(u.username) = LOWER($1) + AND ub.lifted_at IS NULL + AND ub.banned_until > CURRENT_TIMESTAMP + ORDER BY ub.banned_until DESC + LIMIT 1` + + ban := &ActiveBan{} + err := DB.QueryRow(query, username).Scan(&ban.UserID, &ban.Reason, &ban.BannedUntil, &ban.CreatedAt) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return ban, nil +} + +func CreateUserBan(userID int64, reason string, bannedUntil time.Time, bannedBy *int64) error { + if reason == "" { + reason = "Violation of community guidelines" + } + + _, err := DB.Exec(`UPDATE userbans SET lifted_at = $2 WHERE user_id = $1 AND lifted_at IS NULL`, userID, time.Now().UTC()) + if err != nil { + return fmt.Errorf("failed to close existing bans: %w", err) + } + + _, err = DB.Exec(`INSERT INTO userbans (user_id, reason, banned_until, created_at, banned_by) VALUES ($1, $2, $3, $4, $5)`, + userID, + reason, + bannedUntil.UTC(), + time.Now().UTC(), + bannedBy, + ) + if err != nil { + return fmt.Errorf("failed to create ban: %w", err) + } + + return nil +} + +func LiftUserBan(userID int64) error { + _, err := DB.Exec(`UPDATE userbans SET lifted_at = $2 WHERE user_id = $1 AND lifted_at IS NULL`, userID, time.Now().UTC()) + return err +} diff --git a/backend/api/internal/database/comment_queries.go b/backend/api/internal/database/comment_queries.go index 1bd1d8e..63a89ba 100644 --- a/backend/api/internal/database/comment_queries.go +++ b/backend/api/internal/database/comment_queries.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "time" ) @@ -540,6 +541,45 @@ func QueryDeleteComment(id int) (int16, error) { return http.StatusOK, nil } +// QueryCommentsByFilter searches comments by content substring (case-insensitive). +func QueryCommentsByFilter(filter string) ([]Comment, error) { + like := "%" + strings.ToLower(strings.TrimSpace(filter)) + "%" + query := `SELECT id, user_id, content, COALESCE(media, '[]'), likes, creation_date, parent_comment_id + FROM comments + WHERE LOWER(content) LIKE $1 + ORDER BY creation_date DESC + LIMIT 500;` + + rows, err := DB.Query(query, like) + if err != nil { + return nil, err + } + defer rows.Close() + + comments := []Comment{} + for rows.Next() { + var comment Comment + var mediaJSON string + if err := rows.Scan( + &comment.ID, + &comment.User, + &comment.Content, + &mediaJSON, + &comment.Likes, + &comment.CreationDate, + &comment.ParentComment, + ); err != nil { + return nil, err + } + if err := UnmarshalFromJSON(mediaJSON, &comment.Media); err != nil { + return nil, err + } + comments = append(comments, comment) + } + + return comments, nil +} + // QueryUpdateComment updates comment fields with validation on edit time. // // Parameters: diff --git a/backend/api/internal/database/create_tables.sql b/backend/api/internal/database/create_tables.sql index b2937b5..e1851e0 100644 --- a/backend/api/internal/database/create_tables.sql +++ b/backend/api/internal/database/create_tables.sql @@ -183,6 +183,28 @@ CREATE TABLE IF NOT EXISTS userpushtokens ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); +-- Admin Users (users allowed to access admin console via credentials) +CREATE TABLE IF NOT EXISTS adminusers ( + user_id INTEGER PRIMARY KEY, + granted_at TIMESTAMP NOT NULL, + granted_by INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL +); + +-- Timed User Bans +CREATE TABLE IF NOT EXISTS userbans ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + reason TEXT NOT NULL, + banned_until TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + banned_by INTEGER, + lifted_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (banned_by) REFERENCES users(id) ON DELETE SET NULL +); + -- Indexes for performance CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner); @@ -208,3 +230,6 @@ CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); CREATE INDEX IF NOT EXISTS idx_notifications_user_created_at ON notifications(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_notifications_user_read_at ON notifications(user_id, read_at); CREATE INDEX IF NOT EXISTS idx_userpushtokens_user_id ON userpushtokens(user_id); +CREATE INDEX IF NOT EXISTS idx_adminusers_user_id ON adminusers(user_id); +CREATE INDEX IF NOT EXISTS idx_userbans_user_id ON userbans(user_id); +CREATE INDEX IF NOT EXISTS idx_userbans_user_active ON userbans(user_id, lifted_at, banned_until); diff --git a/backend/api/internal/database/create_test_data.sql b/backend/api/internal/database/create_test_data.sql index 8cb6aef..58128fb 100644 --- a/backend/api/internal/database/create_test_data.sql +++ b/backend/api/internal/database/create_test_data.sql @@ -8,10 +8,10 @@ INSERT INTO Users (username, picture, bio, links, settings, creation_date) VALUE -- Projects INSERT INTO Projects (name, description, status, likes, tags, links, owner, creation_date) VALUES - ('OpenAPI Toolkit', 'A toolkit for generating and testing OpenAPI specs.', 1, 120, '["OpenAPI", "Go", "Tooling"]', '["https://github.com/dev_user1/openapi-toolkit"]', 1, '2023-06-13 00:00:00'), - ('DocuHelper', 'A library for streamlining technical documentation processes.', 2, 85, '["Documentation", "Python"]', '["https://github.com/tech_writer2/docuhelper"]', 2, '2021-12-13 00:00:00'), - ('ML Research', 'Research repository for various machine learning algorithms.', 1, 45, '["Machine Learning", "Python", "Research"]', '["https://github.com/data_scientist3/ml-research"]', 3, '2024-09-13 00:00:00'), - ('ScaleDB', 'A scalable database system for modern apps.', 1, 70, '["Database", "Scalability", "Backend"]', '["https://github.com/backend_guru4/scaledb"]', 4, '2024-03-15 00:00:00'); + ('OpenAPI Toolkit', 'A toolkit for generating and testing OpenAPI specs.', 1, 120, '["OpenAPI", "Go", "Tooling"]', '["https://github.com/dev_user1/openapi-toolkit"]', (SELECT id FROM Users WHERE username = 'dev_user1'), '2023-06-13 00:00:00'), + ('DocuHelper', 'A library for streamlining technical documentation processes.', 2, 85, '["Documentation", "Python"]', '["https://github.com/tech_writer2/docuhelper"]', (SELECT id FROM Users WHERE username = 'tech_writer2'), '2021-12-13 00:00:00'), + ('ML Research', 'Research repository for various machine learning algorithms.', 1, 45, '["Machine Learning", "Python", "Research"]', '["https://github.com/data_scientist3/ml-research"]', (SELECT id FROM Users WHERE username = 'data_scientist3'), '2024-09-13 00:00:00'), + ('ScaleDB', 'A scalable database system for modern apps.', 1, 70, '["Database", "Scalability", "Backend"]', '["https://github.com/backend_guru4/scaledb"]', (SELECT id FROM Users WHERE username = 'backend_guru4'), '2024-03-15 00:00:00'); -- Posts INSERT INTO Posts (content, project_id, creation_date, user_id, likes) VALUES diff --git a/backend/api/internal/database/db.go b/backend/api/internal/database/db.go index 2a94af8..1ca9148 100644 --- a/backend/api/internal/database/db.go +++ b/backend/api/internal/database/db.go @@ -18,6 +18,23 @@ var DB *sql.DB // Global database instance // driverName stores the active database driver ("postgres" or "sqlite"). var driverName string +func resolveSqlitePath() string { + candidates := []string{ + filepath.Join(".", "api", "internal", "database", "dev.sqlite3"), + filepath.Join(".", "internal", "database", "dev.sqlite3"), + filepath.Join(".", "dev.sqlite3"), + } + + for _, candidate := range candidates { + dir := filepath.Dir(candidate) + if stat, err := os.Stat(dir); err == nil && stat.IsDir() { + return candidate + } + } + + return filepath.Join(".", "internal", "database", "dev.sqlite3") +} + // Connect initializes a database connection func Connect() { var err error @@ -55,7 +72,10 @@ func Connect() { } else { // Fallback to SQLite for local development driverName = "sqlite" - dbPath := filepath.Join(".", "api", "internal", "database", "dev.sqlite3") + dbPath := resolveSqlitePath() + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + log.Fatalf("Failed to create sqlite directory: %v", err) + } dsn = dbPath } diff --git a/backend/api/internal/database/dev.sqlite3-shm b/backend/api/internal/database/dev.sqlite3-shm index 76b0190..f70b4c0 100644 Binary files a/backend/api/internal/database/dev.sqlite3-shm and b/backend/api/internal/database/dev.sqlite3-shm differ diff --git a/backend/api/internal/database/post_queries.go b/backend/api/internal/database/post_queries.go index 0570901..348c560 100644 --- a/backend/api/internal/database/post_queries.go +++ b/backend/api/internal/database/post_queries.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "time" ) @@ -33,7 +34,7 @@ func QueryPost(id int) (*Post, error) { &post.Content, &mediaJSON, &post.Likes, - &post.Saves, + &post.Saves, &post.CreationDate, ) if err != nil { @@ -209,6 +210,58 @@ func QueryPostsByProjectId(projectId int) ([]Post, int, error) { return posts, http.StatusOK, nil } +// QueryPostsByFilter searches posts by content substring (case-insensitive). Returns posts matching content. +func QueryPostsByFilter(filter string) ([]Post, error) { + like := "%" + strings.ToLower(strings.TrimSpace(filter)) + "%" + query := `SELECT id, user_id, project_id, content, COALESCE(media, '[]'), + COALESCE((SELECT COUNT(*) FROM postlikes pl WHERE pl.post_id = posts.id), 0), + COALESCE((SELECT COUNT(*) FROM postsaves ps WHERE ps.post_id = posts.id), 0), + creation_date + FROM posts WHERE LOWER(content) LIKE $1 LIMIT 500;` + + rows, err := DB.Query(query, like) + if err != nil { + return nil, err + } + defer rows.Close() + + posts := []Post{} + for rows.Next() { + var id sql.NullInt64 + var userID sql.NullInt64 + var projectID sql.NullInt64 + var content sql.NullString + var mediaJSON string + var likes sql.NullInt64 + var saves sql.NullInt64 + var creationDate sql.NullTime + + if err := rows.Scan(&id, &userID, &projectID, &content, &mediaJSON, &likes, &saves, &creationDate); err != nil { + return nil, err + } + if !id.Valid || !userID.Valid || !projectID.Valid { + continue + } + + post := Post{ + ID: int64(id.Int64), + User: userID.Int64, + Project: projectID.Int64, + Content: content.String, + Likes: likes.Int64, + Saves: saves.Int64, + } + if creationDate.Valid { + post.CreationDate = creationDate.Time + } + if err := UnmarshalFromJSON(mediaJSON, &post.Media); err != nil { + return nil, err + } + posts = append(posts, post) + } + return posts, nil +} + func CreatePostLike(username string, postId string) (int, error) { userID, err := GetUserIdByUsername(username) if err != nil { @@ -330,4 +383,3 @@ func QueryPostLike(username string, postId string) (int, bool, error) { } return http.StatusOK, exists, nil } - diff --git a/backend/api/internal/database/project_queries.go b/backend/api/internal/database/project_queries.go index 4276f26..994b232 100644 --- a/backend/api/internal/database/project_queries.go +++ b/backend/api/internal/database/project_queries.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "time" ) @@ -804,3 +805,77 @@ func QueryProjectLike(username string, strProjId string) (int, bool, error) { } } + +// QueryProjectsByFilter searches projects by name substring (case-insensitive). +func QueryProjectsByFilter(filter string) ([]Project, error) { + like := "%" + strings.ToLower(strings.TrimSpace(filter)) + "%" + query := `SELECT id, name, description, COALESCE(about_md, ''), status, likes, + COALESCE((SELECT COUNT(*) FROM projectfollows pf WHERE pf.project_id = projects.id), 0), + COALESCE(links, '[]'), COALESCE(tags, '[]'), COALESCE(media, '[]'), owner, creation_date + FROM projects WHERE LOWER(name) LIKE $1 LIMIT 500;` + rows, err := DB.Query(query, like) + if err != nil { + return nil, err + } + defer rows.Close() + + projects := []Project{} + for rows.Next() { + var id sql.NullInt64 + var name sql.NullString + var description sql.NullString + var aboutMd sql.NullString + var status sql.NullInt64 + var likes sql.NullInt64 + var saves sql.NullInt64 + var linksJSON, tagsJSON, mediaJSON string + var owner sql.NullInt64 + var creationDate sql.NullTime + if err := rows.Scan( + &id, + &name, + &description, + &aboutMd, + &status, + &likes, + &saves, + &linksJSON, + &tagsJSON, + &mediaJSON, + &owner, + &creationDate, + ); err != nil { + return nil, err + } + if !id.Valid || !owner.Valid { + continue + } + + project := Project{ + ID: int64(id.Int64), + Name: name.String, + Description: description.String, + AboutMd: aboutMd.String, + Likes: likes.Int64, + Saves: saves.Int64, + Owner: owner.Int64, + } + if status.Valid { + project.Status = int16(status.Int64) + } + if creationDate.Valid { + project.CreationDate = creationDate.Time + } + if err := UnmarshalFromJSON(linksJSON, &project.Links); err != nil { + return nil, err + } + if err := UnmarshalFromJSON(tagsJSON, &project.Tags); err != nil { + return nil, err + } + if err := UnmarshalFromJSON(mediaJSON, &project.Media); err != nil { + return nil, err + } + projects = append(projects, project) + } + return projects, nil +} diff --git a/backend/api/internal/database/user_queries.go b/backend/api/internal/database/user_queries.go index 5e2a993..3a80406 100644 --- a/backend/api/internal/database/user_queries.go +++ b/backend/api/internal/database/user_queries.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log" + "strings" "time" ) @@ -23,6 +24,56 @@ type UserLoginInfo struct { PasswordHash string `json:"password_hash"` } +func scanApiUserRow(rows *sql.Rows) (*ApiUser, error) { + var id sql.NullInt64 + var username sql.NullString + var picture sql.NullString + var bio sql.NullString + var links []byte + var settings []byte + var creationDate sql.NullString + + if err := rows.Scan( + &id, + &username, + &picture, + &bio, + &links, + &settings, + &creationDate, + ); err != nil { + return nil, err + } + + if !id.Valid || !username.Valid { + log.Printf("WARN: skipping malformed user row (id_valid=%v username_valid=%v)", id.Valid, username.Valid) + return nil, nil + } + + user := &ApiUser{ + Id: int(id.Int64), + Username: username.String, + Picture: picture.String, + Bio: bio.String, + CreationDate: creationDate.String, + Links: []string{}, + Settings: map[string]interface{}{}, + } + + if len(links) > 0 { + if err := json.Unmarshal(links, &user.Links); err != nil { + log.Printf("WARN: could not unmarshal user links: %v", err) + } + } + if len(settings) > 0 { + if err := json.Unmarshal(settings, &user.Settings); err != nil { + log.Printf("WARN: could not unmarshal user settings: %v", err) + } + } + + return user, nil +} + // CreateUser inserts a new user into the database func CreateUser(user *ApiUser) (int, error) { linksJson, err := json.Marshal(user.Links) @@ -242,25 +293,44 @@ func GetUsers() ([]*ApiUser, error) { var users []*ApiUser for rows.Next() { - user := &ApiUser{} - var links, settings []byte - if err := rows.Scan( - &user.Id, - &user.Username, - &user.Picture, - &user.Bio, - &links, - &settings, - &user.CreationDate, - ); err != nil { + user, err := scanApiUserRow(rows) + if err != nil { return nil, fmt.Errorf("failed to scan user row: %w", err) } + if user == nil { + continue + } + users = append(users, user) + } - if err := json.Unmarshal(links, &user.Links); err != nil { - log.Printf("WARN: could not unmarshal user links: %v", err) + return users, nil +} + +// QueryUsersByFilter searches users by username substring (case-insensitive). +// Limits results to 200 rows to avoid returning an excessively large list. +func QueryUsersByFilter(filter string) ([]*ApiUser, error) { + like := "%" + strings.ToLower(strings.TrimSpace(filter)) + "%" + query := ` + SELECT id, username, picture, bio, links, settings, creation_date + FROM users + WHERE LOWER(username) LIKE $1 + ORDER BY id + LIMIT 200; + ` + rows, err := DB.Query(query, like) + if err != nil { + return nil, fmt.Errorf("failed to query users by filter: %w", err) + } + defer rows.Close() + + var users []*ApiUser + for rows.Next() { + user, err := scanApiUserRow(rows) + if err != nil { + return nil, fmt.Errorf("failed to scan user row: %w", err) } - if err := json.Unmarshal(settings, &user.Settings); err != nil { - log.Printf("WARN: could not unmarshal user settings: %v", err) + if user == nil { + continue } users = append(users, user) } diff --git a/backend/api/internal/handlers/admin_routes.go b/backend/api/internal/handlers/admin_routes.go new file mode 100644 index 0000000..f198da9 --- /dev/null +++ b/backend/api/internal/handlers/admin_routes.go @@ -0,0 +1,355 @@ +package handlers + +import ( + "database/sql" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "backend/api/internal/database" + + "github.com/gin-gonic/gin" +) + +type AdminUserRow struct { + ID int `json:"id"` + Username string `json:"username"` + Picture string `json:"picture"` + Bio string `json:"bio"` + CreationDate string `json:"creation_date"` + IsAdmin bool `json:"is_admin"` + BanReason string `json:"ban_reason,omitempty"` + BanUntil *time.Time `json:"ban_until,omitempty"` +} + +// AdminListUsers returns all users (admin-only) +func AdminListUsers(c *gin.Context) { + q := c.Query("q") + var users []*database.ApiUser + var err error + if strings.TrimSpace(q) != "" { + users, err = database.QueryUsersByFilter(q) + } else { + users, err = database.GetUsers() + } + if err != nil { + RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Failed to fetch users: %v", err)) + return + } + + rows := make([]AdminUserRow, 0, len(users)) + for _, user := range users { + if user == nil { + continue + } + + isAdmin, err := database.IsUserAdmin(int64(user.Id)) + if err != nil { + RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Failed to resolve admin status: %v", err)) + return + } + + row := AdminUserRow{ + ID: user.Id, + Username: user.Username, + Picture: user.Picture, + Bio: user.Bio, + CreationDate: user.CreationDate, + IsAdmin: isAdmin, + } + + activeBan, err := database.GetActiveBanByUserID(int64(user.Id)) + if err != nil { + RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Failed to resolve ban status: %v", err)) + return + } + if activeBan != nil { + row.BanReason = activeBan.Reason + banUntil := activeBan.BannedUntil + row.BanUntil = &banUntil + } + + rows = append(rows, row) + } + + c.JSON(http.StatusOK, rows) +} + +type setAdminRequest struct { + IsAdmin bool `json:"is_admin"` +} + +func AdminSetUserAdmin(c *gin.Context) { + username := strings.TrimSpace(c.Param("username")) + if username == "" { + RespondWithError(c, http.StatusBadRequest, "Missing username") + return + } + + target, err := database.GetUserByUsername(username) + if err != nil || target == nil { + RespondWithError(c, http.StatusNotFound, "User not found") + return + } + + var payload setAdminRequest + if err := c.BindJSON(&payload); err != nil { + RespondWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid payload: %v", err)) + return + } + + var grantedBy *int64 + if authUserID, ok := GetAuthUserID(c); ok { + grantedBy = &authUserID + } + + if err := database.SetUserAdmin(int64(target.Id), grantedBy, payload.IsAdmin); err != nil { + RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Failed to update admin status: %v", err)) + return + } + + state := "removed" + if payload.IsAdmin { + state = "granted" + } + c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Admin access %s for %s", state, target.Username)}) +} + +type banUserRequest struct { + Reason string `json:"reason"` + DurationMinutes int `json:"duration_minutes"` +} + +func AdminBanUser(c *gin.Context) { + username := strings.TrimSpace(c.Param("username")) + if username == "" { + RespondWithError(c, http.StatusBadRequest, "Missing username") + return + } + + target, err := database.GetUserByUsername(username) + if err != nil || target == nil { + RespondWithError(c, http.StatusNotFound, "User not found") + return + } + + var payload banUserRequest + if err := c.BindJSON(&payload); err != nil { + RespondWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid payload: %v", err)) + return + } + if payload.DurationMinutes <= 0 { + RespondWithError(c, http.StatusBadRequest, "duration_minutes must be > 0") + return + } + + bannedUntil := time.Now().UTC().Add(time.Duration(payload.DurationMinutes) * time.Minute) + var bannedBy *int64 + if authUserID, ok := GetAuthUserID(c); ok { + bannedBy = &authUserID + } + + if err := database.CreateUserBan(int64(target.Id), strings.TrimSpace(payload.Reason), bannedUntil, bannedBy); err != nil { + RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Failed to ban user: %v", err)) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("User %s banned", target.Username), + "reason": strings.TrimSpace(payload.Reason), + "banned_until": bannedUntil.Format(time.RFC3339), + }) +} + +func AdminUnbanUser(c *gin.Context) { + username := strings.TrimSpace(c.Param("username")) + if username == "" { + RespondWithError(c, http.StatusBadRequest, "Missing username") + return + } + + target, err := database.GetUserByUsername(username) + if err != nil || target == nil { + RespondWithError(c, http.StatusNotFound, "User not found") + return + } + + if err := database.LiftUserBan(int64(target.Id)); err != nil { + RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Failed to lift ban: %v", err)) + return + } + + c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Ban lifted for %s", target.Username)}) +} + +func AdminMe(c *gin.Context) { + authMode, _ := c.Get("adminAuthMode") + response := gin.H{ + "mode": authMode, + } + + if username := c.GetString(authUsernameKey); username != "" { + response["username"] = username + } + + c.JSON(http.StatusOK, response) +} + +// AdminDeleteUser deletes a user by username (admin-only). It mirrors the normal DeleteUser logic +// but is callable by an admin key. +func AdminDeleteUser(c *gin.Context) { + username := c.Param("username") + existingUser, err := database.GetUserByUsername(username) + if err != nil { + RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Failed to resolve user before delete: %v", err)) + return + } + if existingUser == nil { + RespondWithError(c, http.StatusNotFound, fmt.Sprintf("User '%v' not found.", username)) + return + } + + managedUploads, err := collectManagedUploadsForUser(existingUser.Id) + if err != nil { + RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Failed to collect user media before delete: %v", err)) + return + } + + err = database.DeleteUser(username) + if err != nil { + RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Failed to delete user: %v", err)) + return + } + + removedFiles := removeManagedUploadFiles(managedUploads) + removedOwnedOrphans := removeOwnerPrefixedUploadFiles(existingUser.Id, managedUploads) + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("User '%v' deleted.", username), + "removed_uploads": removedFiles, + "removed_orphan_uploads": removedOwnedOrphans, + }) +} + +// AdminDeletePost deletes a post by id (admin-only) +func AdminDeletePost(c *gin.Context) { + strId := c.Param("post_id") + id, err := strconv.Atoi(strId) + if err != nil { + RespondWithError(c, http.StatusBadRequest, fmt.Sprintf("Failed to parse post id: %v", err)) + return + } + httpCode, err := database.QueryDeletePost(id) + if err != nil { + RespondWithError(c, int(httpCode), fmt.Sprintf("Failed to delete post: %v", err)) + return + } + c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Post %v deleted.", id)}) +} + +// AdminListPosts lists posts, optionally filtered by `q` (content substring) +func AdminListPosts(c *gin.Context) { + q := strings.TrimSpace(c.Query("q")) + posts, err := database.QueryPostsByFilter(q) + if err != nil { + RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Failed to search posts: %v", err)) + return + } + c.JSON(http.StatusOK, posts) +} + +// AdminListProjects lists projects/streams by name filter +func AdminListProjects(c *gin.Context) { + q := strings.TrimSpace(c.Query("q")) + projects, err := database.QueryProjectsByFilter(q) + if err != nil { + RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Failed to search projects: %v", err)) + return + } + c.JSON(http.StatusOK, projects) +} + +// AdminDeleteProject deletes a project/stream by id (admin-only) +func AdminDeleteProject(c *gin.Context) { + strId := c.Param("project_id") + id, err := strconv.Atoi(strId) + if err != nil { + RespondWithError(c, http.StatusBadRequest, fmt.Sprintf("Failed to parse project id: %v", err)) + return + } + httpCode, err := database.QueryDeleteProject(id) + if err != nil { + RespondWithError(c, int(httpCode), fmt.Sprintf("Failed to delete project: %v", err)) + return + } + c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Project %v deleted.", id)}) +} + +func AdminListComments(c *gin.Context) { + q := strings.TrimSpace(c.Query("q")) + comments, err := database.QueryCommentsByFilter(q) + if err != nil { + RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Failed to search comments: %v", err)) + return + } + c.JSON(http.StatusOK, comments) +} + +func AdminDeleteComment(c *gin.Context) { + strId := c.Param("comment_id") + id, err := strconv.Atoi(strId) + if err != nil { + RespondWithError(c, http.StatusBadRequest, fmt.Sprintf("Failed to parse comment id: %v", err)) + return + } + httpCode, err := database.QueryDeleteComment(id) + if err != nil { + RespondWithError(c, int(httpCode), fmt.Sprintf("Failed to delete comment: %v", err)) + return + } + c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Comment %v deleted.", id)}) +} + +func AdminOverview(c *gin.Context) { + type counts struct { + Users int `json:"users"` + Posts int `json:"posts"` + Projects int `json:"projects"` + Comments int `json:"comments"` + } + + var payload counts + var dbNow sql.NullTime + + queries := []struct { + stmt string + out *int + }{ + {stmt: "SELECT COUNT(*) FROM users", out: &payload.Users}, + {stmt: "SELECT COUNT(*) FROM posts", out: &payload.Posts}, + {stmt: "SELECT COUNT(*) FROM projects", out: &payload.Projects}, + {stmt: "SELECT COUNT(*) FROM comments", out: &payload.Comments}, + } + + for _, query := range queries { + if err := database.DB.QueryRow(query.stmt).Scan(query.out); err != nil { + RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Failed running admin overview query: %v", err)) + return + } + } + + if err := database.DB.QueryRow("SELECT CURRENT_TIMESTAMP").Scan(&dbNow); err != nil { + dbNow = sql.NullTime{Valid: false} + } + + response := gin.H{ + "counts": payload, + "server_time": time.Now().UTC().Format(time.RFC3339), + } + if dbNow.Valid { + response["db_time"] = dbNow.Time.UTC().Format(time.RFC3339) + } + + c.JSON(http.StatusOK, response) +} diff --git a/backend/api/internal/handlers/auth_middleware.go b/backend/api/internal/handlers/auth_middleware.go index e510e94..506719f 100644 --- a/backend/api/internal/handlers/auth_middleware.go +++ b/backend/api/internal/handlers/auth_middleware.go @@ -1,10 +1,13 @@ package handlers import ( + "net" "net/http" + "os" "strings" "backend/api/internal/auth" + "backend/api/internal/database" "github.com/gin-gonic/gin" ) @@ -35,6 +38,65 @@ func RequireAuth() gin.HandlerFunc { } } +func RequireAdmin() gin.HandlerFunc { + return func(context *gin.Context) { + adminKey := os.Getenv("DEVBITS_ADMIN_KEY") + provided := context.GetHeader("X-Admin-Key") + if adminKey != "" && provided != "" && provided == adminKey { + context.Set("adminAuthMode", "key") + context.Next() + return + } + + authorization := context.GetHeader("Authorization") + if authorization == "" || !strings.HasPrefix(authorization, "Bearer ") { + RespondWithError(context, http.StatusUnauthorized, "Admin authentication required") + context.Abort() + return + } + + token := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer ")) + claims, err := auth.ParseToken(token) + if err != nil { + RespondWithError(context, http.StatusUnauthorized, "Invalid admin token") + context.Abort() + return + } + + isAdmin, err := database.IsUserAdmin(claims.UserID) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, "Failed to verify admin privileges") + context.Abort() + return + } + if !isAdmin { + RespondWithError(context, http.StatusForbidden, "Admin privileges required") + context.Abort() + return + } + + context.Set(authUserIDKey, claims.UserID) + context.Set(authUsernameKey, claims.Username) + context.Set("adminAuthMode", "token") + + context.Next() + } +} + +func RequireLocalhost() gin.HandlerFunc { + return func(context *gin.Context) { + clientIP := strings.TrimSpace(context.ClientIP()) + parsed := net.ParseIP(clientIP) + if parsed == nil || !parsed.IsLoopback() { + RespondWithError(context, http.StatusForbidden, "Admin console is local-only") + context.Abort() + return + } + + context.Next() + } +} + func RequireSameUser() gin.HandlerFunc { return func(context *gin.Context) { paramUsername := strings.TrimSpace(context.Param("username")) diff --git a/backend/api/internal/handlers/auth_routes.go b/backend/api/internal/handlers/auth_routes.go index c99aa93..5a3a520 100644 --- a/backend/api/internal/handlers/auth_routes.go +++ b/backend/api/internal/handlers/auth_routes.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "strings" + "time" "backend/api/internal/auth" "backend/api/internal/database" @@ -134,6 +135,26 @@ func Login(context *gin.Context) { return } + activeBan, err := database.GetActiveBanByUserID(int64(user.Id)) + if err != nil { + RespondWithError(context, http.StatusInternalServerError, "Failed to verify account status") + return + } + if activeBan != nil { + remainingSeconds := int(time.Until(activeBan.BannedUntil).Seconds()) + if remainingSeconds < 0 { + remainingSeconds = 0 + } + context.JSON(http.StatusForbidden, gin.H{ + "error": "Account banned", + "message": "Your account is temporarily banned.", + "reason": activeBan.Reason, + "banned_until": activeBan.BannedUntil.UTC().Format(time.RFC3339), + "seconds_remaining": remainingSeconds, + }) + return + } + token, err := auth.GenerateToken(int64(user.Id), user.Username) if err != nil { RespondWithError(context, http.StatusInternalServerError, "Failed to issue token") diff --git a/backend/api/internal/tests/dev.sqlite3 b/backend/api/internal/tests/dev.sqlite3 new file mode 100644 index 0000000..159e90a Binary files /dev/null and b/backend/api/internal/tests/dev.sqlite3 differ diff --git a/backend/api/internal/tests/dev.sqlite3-shm b/backend/api/internal/tests/dev.sqlite3-shm new file mode 100644 index 0000000..684066a Binary files /dev/null and b/backend/api/internal/tests/dev.sqlite3-shm differ diff --git a/backend/api/internal/tests/dev.sqlite3-wal b/backend/api/internal/tests/dev.sqlite3-wal new file mode 100644 index 0000000..c880562 Binary files /dev/null and b/backend/api/internal/tests/dev.sqlite3-wal differ diff --git a/backend/api/internal/tests/main_test.go b/backend/api/internal/tests/main_test.go index 38baf53..d9268da 100644 --- a/backend/api/internal/tests/main_test.go +++ b/backend/api/internal/tests/main_test.go @@ -157,7 +157,11 @@ func loadSQLFile(db *sql.DB, filename string, replacements ...string) error { continue } if _, err := db.Exec(stmt); err != nil { - return fmt.Errorf("failed to exec statement from %s: %v", path, err) + stmtPreview := stmt + if len(stmtPreview) > 220 { + stmtPreview = stmtPreview[:220] + "..." + } + return fmt.Errorf("failed to exec statement from %s: %v\nstatement: %s", path, err, stmtPreview) } } return nil @@ -233,13 +237,32 @@ func TestAPI(t *testing.T) { logger.InitLogger() database.DB = nil - database.Connect() - db := database.DB - if db == nil { - t.Fatal("Failed to connect to database") + testDbPath := filepath.Join(t.TempDir(), "api_tests.sqlite3") + db, err := sql.Open("sqlite", testDbPath) + if err != nil { + t.Fatalf("Failed to open test sqlite database: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + + if _, err := db.Exec("PRAGMA foreign_keys=ON;"); err != nil { + t.Fatalf("Failed to enable sqlite foreign keys: %v", err) + } + if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil { + t.Fatalf("Failed to set sqlite WAL mode: %v", err) } + if _, err := db.Exec("PRAGMA busy_timeout=5000;"); err != nil { + t.Fatalf("Failed to set sqlite busy timeout: %v", err) + } + + database.DB = db - if err := loadSQLFile(db, "create_tables.sql"); err != nil { + if err := loadSQLFile( + db, + "create_tables.sql", + "SERIAL PRIMARY KEY", "INTEGER PRIMARY KEY AUTOINCREMENT", + ); err != nil { t.Fatalf("Failed to load schema: %v", err) } if err := loadSQLFile(db, "create_test_data.sql"); err != nil { diff --git a/backend/api/internal/tests/user_test.go b/backend/api/internal/tests/user_test.go index 27a187e..290cd39 100644 --- a/backend/api/internal/tests/user_test.go +++ b/backend/api/internal/tests/user_test.go @@ -38,13 +38,13 @@ var user_tests = []TestCase{ AuthAs: "dev_user1:1", }, - // creating same user again – UNIQUE constraint error (PostgreSQL) + // creating same user again – UNIQUE constraint error { Method: http.MethodPost, Endpoint: "/users", Input: `{"username":"new_user","bio":"This is a test user.","links":["https://example.com","https://another-link.com"],"picture":"https://example.com/profile.jpg"}`, ExpectedStatus: http.StatusInternalServerError, - ExpectedBody: `{"error":"Internal Server Error","message":"Failed to create user: failed to insert user: pq: duplicate key value violates unique constraint \"users_username_key\" (23505)"}`, + ExpectedBody: `{"error":"Internal Server Error","message":"Failed to create user: failed to insert user: constraint failed: UNIQUE constraint failed: users.username (2067)"}`, AuthAs: "dev_user1:1", }, diff --git a/backend/api/main.go b/backend/api/main.go index 1f56942..92fa6df 100644 --- a/backend/api/main.go +++ b/backend/api/main.go @@ -1,8 +1,10 @@ package main import ( + "github.com/joho/godotenv" "log" "os" + "path/filepath" "strings" "backend/api/internal/database" @@ -17,6 +19,7 @@ import ( const debugEnvKey = "DEVBITS_DEBUG" const corsOriginsEnvKey = "DEVBITS_CORS_ORIGINS" +const listenAddrEnvKey = "DEVBITS_API_ADDR" func isDebugMode() bool { return os.Getenv(debugEnvKey) == "1" @@ -47,11 +50,39 @@ func getAllowedOrigins() []string { } } +func getListenAddr() string { + addr := strings.TrimSpace(os.Getenv(listenAddrEnvKey)) + if addr != "" { + return addr + } + return "0.0.0.0:8080" +} + func HealthCheck(context *gin.Context) { context.JSON(200, gin.H{"message": "API is running!"}) } +func resolveAdminDir() string { + candidates := []string{ + "./admin", + "../admin", + "../../backend/api/admin", + "./backend/api/admin", + } + + for _, candidate := range candidates { + indexFile := filepath.Join(candidate, "index.html") + if _, err := os.Stat(indexFile); err == nil { + return candidate + } + } + + return "./admin" +} + func main() { + // Load local .env for development (ignored if missing) + _ = godotenv.Load(".env", "../.env", "../../.env") if isDebugMode() { gin.SetMode(gin.DebugMode) } else { @@ -105,6 +136,63 @@ func main() { router.Use(cors.New(corsConfig)) router.Static("/uploads", "./uploads") + adminDir := resolveAdminDir() + log.Printf("INFO: admin UI dir: %s", adminDir) + adminLocalOnly := strings.EqualFold(strings.TrimSpace(os.Getenv("DEVBITS_ADMIN_LOCAL_ONLY")), "1") + if adminLocalOnly { + log.Printf("INFO: admin access mode: localhost-only") + } else { + log.Printf("WARN: admin access mode: remote-enabled (DEVBITS_ADMIN_LOCAL_ONLY=0)") + } + + adminLocal := router.Group("/admin") + if adminLocalOnly { + adminLocal.Use(handlers.RequireLocalhost()) + } + adminLocal.GET("", func(c *gin.Context) { + c.File(filepath.Join(adminDir, "index.html")) + }) + adminLocal.GET("/", func(c *gin.Context) { + c.File(filepath.Join(adminDir, "index.html")) + }) + adminLocal.GET("/console", func(c *gin.Context) { + c.File(filepath.Join(adminDir, "console.html")) + }) + adminLocal.GET("/console/", func(c *gin.Context) { + c.File(filepath.Join(adminDir, "console.html")) + }) + adminLocal.Static("/static", filepath.Join(adminDir, "static")) + + adminApi := router.Group("/admin") + if adminLocalOnly { + adminApi.Use(handlers.RequireLocalhost(), handlers.RequireAdmin()) + } else { + adminApi.Use(handlers.RequireAdmin()) + } + adminApi.GET("/overview", handlers.AdminOverview) + adminApi.GET("/me", handlers.AdminMe) + adminApi.GET("/users", handlers.AdminListUsers) + adminApi.POST("/users/:username/admin", handlers.AdminSetUserAdmin) + adminApi.POST("/users/:username/ban", handlers.AdminBanUser) + adminApi.POST("/users/:username/unban", handlers.AdminUnbanUser) + adminApi.DELETE("/users/:username", handlers.AdminDeleteUser) + adminApi.GET("/posts", handlers.AdminListPosts) + adminApi.DELETE("/posts/:post_id", handlers.AdminDeletePost) + adminApi.GET("/projects", handlers.AdminListProjects) + adminApi.DELETE("/projects/:project_id", handlers.AdminDeleteProject) + adminApi.GET("/comments", handlers.AdminListComments) + adminApi.DELETE("/comments/:comment_id", handlers.AdminDeleteComment) + + if strings.TrimSpace(os.Getenv("DEVBITS_ADMIN_KEY")) == "" { + log.Printf("WARN: DEVBITS_ADMIN_KEY is empty; admin API calls will be rejected") + } + if _, err := os.Stat(filepath.Join(adminDir, "index.html")); err != nil { + log.Printf("WARN: admin UI index not found at %s (%v)", filepath.Join(adminDir, "index.html"), err) + } else if adminLocalOnly { + log.Printf("INFO: admin UI available at /admin (localhost only)") + } else { + log.Printf("INFO: admin UI available at /admin (key-protected)") + } router.GET("/", func(c *gin.Context) { c.String(200, "Welcome to the DevBits API! Everything is running correctly.") @@ -212,10 +300,12 @@ func main() { router.DELETE("/notifications/:notification_id", handlers.RequireAuth(), handlers.DeleteNotification) router.DELETE("/notifications", handlers.RequireAuth(), handlers.ClearNotifications) - if err := router.Run("0.0.0.0:8080"); err != nil { - log.Printf("ERROR: failed to start API server on 0.0.0.0:8080: %v", err) + listenAddr := getListenAddr() + log.Printf("INFO: API listen address: %s", listenAddr) + if err := router.Run(listenAddr); err != nil { + log.Printf("ERROR: failed to start API server on %s: %v", listenAddr, err) if isDebugMode() { - log.Println("HINT: port 8080 is likely already in use. Stop the existing backend process or run only one launcher.") + log.Println("HINT: address is likely in use. Set DEVBITS_API_ADDR (e.g. 127.0.0.1:18080) or stop the existing process.") } os.Exit(1) } diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index e77af63..349c2f0 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -39,6 +39,8 @@ services: condition: service_healthy environment: - DATABASE_URL=postgres://${POSTGRES_USER:-devbits}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/${POSTGRES_DB:-devbits}?sslmode=disable + - DEVBITS_ADMIN_KEY=${DEVBITS_ADMIN_KEY} + - DEVBITS_ADMIN_LOCAL_ONLY=${DEVBITS_ADMIN_LOCAL_ONLY:-0} nginx: image: nginx:latest diff --git a/backend/go.mod b/backend/go.mod index c636648..af52653 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -28,6 +28,7 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/kr/text v0.2.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 8be667e..cfc927a 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -43,6 +43,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= diff --git a/backend/nginx/nginx.conf b/backend/nginx/nginx.conf index 3ebe561..28f1161 100644 --- a/backend/nginx/nginx.conf +++ b/backend/nginx/nginx.conf @@ -209,6 +209,36 @@ http { add_header Cache-Control "no-store" always; } + location = /admin { + proxy_pass http://backend_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store" always; + } + + location ^~ /admin/ { + proxy_pass http://backend_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store" always; + } + location = /api/auth/login { limit_req zone=auth_limit burst=40 nodelay; proxy_pass http://backend_upstream; @@ -471,6 +501,36 @@ http { add_header Cache-Control "no-store" always; } + location = /admin { + proxy_pass http://backend_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store" always; + } + + location ^~ /admin/ { + proxy_pass http://backend_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache off; + proxy_cache_bypass 1; + proxy_no_cache 1; + add_header Cache-Control "no-store" always; + } + # Serve the CSAE (Child Sexual Abuse & Exploitation) standards page location = /csae-standards { default_type text/html; diff --git a/backend/uploads/u10_921dc0c6071003b0f8472d58.jpg b/backend/uploads/u10_921dc0c6071003b0f8472d58.jpg new file mode 100644 index 0000000..df6ee77 Binary files /dev/null and b/backend/uploads/u10_921dc0c6071003b0f8472d58.jpg differ diff --git a/backend/uploads/u2_182a392d5837c19a1ad894b5.jpg b/backend/uploads/u2_182a392d5837c19a1ad894b5.jpg deleted file mode 100644 index c869c71..0000000 Binary files a/backend/uploads/u2_182a392d5837c19a1ad894b5.jpg and /dev/null differ diff --git a/backend/uploads/u2_3118b9426b2d9536f2ef16b7.jpg b/backend/uploads/u2_3118b9426b2d9536f2ef16b7.jpg new file mode 100644 index 0000000..c9c238f Binary files /dev/null and b/backend/uploads/u2_3118b9426b2d9536f2ef16b7.jpg differ diff --git a/backend/uploads/u2_ae17bcd29c6f6e22e6afb0b9.jpg b/backend/uploads/u2_ae17bcd29c6f6e22e6afb0b9.jpg deleted file mode 100644 index ff41674..0000000 Binary files a/backend/uploads/u2_ae17bcd29c6f6e22e6afb0b9.jpg and /dev/null differ diff --git a/backend/uploads/u2_e6541e277902a9bbfe95b888.heic b/backend/uploads/u2_e6541e277902a9bbfe95b888.heic deleted file mode 100644 index 89e8e65..0000000 Binary files a/backend/uploads/u2_e6541e277902a9bbfe95b888.heic and /dev/null differ diff --git a/backend/uploads/u5_16ed9aa407fa7e3c5445325b.jpg b/backend/uploads/u5_16ed9aa407fa7e3c5445325b.jpg new file mode 100644 index 0000000..84632ab Binary files /dev/null and b/backend/uploads/u5_16ed9aa407fa7e3c5445325b.jpg differ diff --git a/backend/uploads/u5_d8d0b793924c9834ebcabf88.jpg b/backend/uploads/u5_d8d0b793924c9834ebcabf88.jpg deleted file mode 100644 index 46b25cd..0000000 Binary files a/backend/uploads/u5_d8d0b793924c9834ebcabf88.jpg and /dev/null differ diff --git a/backend/uploads/u9_c1724846b8f93da55f421234.jpg b/backend/uploads/u9_c1724846b8f93da55f421234.jpg deleted file mode 100644 index 12d554d..0000000 Binary files a/backend/uploads/u9_c1724846b8f93da55f421234.jpg and /dev/null differ diff --git a/backend/uploads/u9_d1b7c2073954b895bed100a5.jpg b/backend/uploads/u9_d1b7c2073954b895bed100a5.jpg new file mode 100644 index 0000000..4fb5285 Binary files /dev/null and b/backend/uploads/u9_d1b7c2073954b895bed100a5.jpg differ diff --git a/frontend/app.json b/frontend/app.json index 03b0e09..a0eb079 100644 --- a/frontend/app.json +++ b/frontend/app.json @@ -14,7 +14,11 @@ "bundleIdentifier": "com.devbits.frontend", "buildNumber": "16", "infoPlist": { - "ITSAppUsesNonExemptEncryption": false + "ITSAppUsesNonExemptEncryption": false, + "NSPhotoLibraryUsageDescription": "DevBits requests access to your photo library to let you pick images to attach to posts or save images from the app. Example: selecting a photo to upload when creating a byte.", + "NSPhotoLibraryAddUsageDescription": "DevBits may save images you create or download (for example, saving a snapshot of a post) to your photo library when you choose to do so.", + "NSCameraUsageDescription": "DevBits requests access to the camera so you can capture photos or videos to attach to posts. Example: taking a photo while creating a byte.", + "NSMicrophoneUsageDescription": "DevBits requests access to the microphone to record audio for posts or streams. Example: recording a short audio clip when creating content." }, "associatedDomains": [ "applinks:devbits.ddns.net"