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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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"