Skip to content

Commit 9fed7ef

Browse files
authored
Merge pull request #50 from hackclub/limit-db-write-access
remove db execute route; sets transactions as read only from admin/database ui
2 parents 0b1d1b8 + 0fb35c0 commit 9fed7ef

3 files changed

Lines changed: 8 additions & 90 deletions

File tree

app/controllers/admin/database_controller.rb

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ def query
1717
end
1818

1919
begin
20-
result = ActiveRecord::Base.connection.exec_query(sql)
20+
result = nil
21+
22+
ActiveRecord::Base.connection.transaction do
23+
ActiveRecord::Base.connection.execute("SET TRANSACTION READ ONLY")
24+
result = ActiveRecord::Base.connection.exec_query(sql)
25+
end
26+
2127
render json: {
2228
columns: result.columns,
2329
rows: result.rows,
@@ -27,19 +33,4 @@ def query
2733
render json: { error: e.message }, status: :unprocessable_entity
2834
end
2935
end
30-
31-
def execute
32-
sql = params[:sql].to_s.strip
33-
34-
if sql.blank?
35-
return render json: { error: "SQL statement cannot be blank" }, status: :unprocessable_entity
36-
end
37-
38-
begin
39-
result = ActiveRecord::Base.connection.execute(sql)
40-
render json: { message: "Executed successfully", affected_rows: result.cmd_tuples }
41-
rescue => e
42-
render json: { error: e.message }, status: :unprocessable_entity
43-
end
44-
end
4536
end

app/javascript/pages/Admin/Database/Index.tsx

Lines changed: 1 addition & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,21 @@
1-
import { useState, useRef } from 'react'
1+
import { useState } from 'react'
22

33
interface QueryResult {
44
columns: string[]
55
rows: (string | number | boolean | null)[][]
66
row_count: number
77
}
88

9-
interface ExecuteResult {
10-
message: string
11-
affected_rows: number
12-
}
13-
149
interface ErrorResult {
1510
error: string
1611
}
1712

1813
export default function AdminDatabaseIndex({ tables }: { tables: string[] }) {
1914
const [sql, setSql] = useState('')
2015
const [result, setResult] = useState<QueryResult | null>(null)
21-
const [executeMsg, setExecuteMsg] = useState<string | null>(null)
2216
const [error, setError] = useState<string | null>(null)
2317
const [loading, setLoading] = useState(false)
2418
const [selectedTable, setSelectedTable] = useState<string | null>(null)
25-
const textareaRef = useRef<HTMLTextAreaElement>(null)
2619

2720
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content || ''
2821

@@ -32,7 +25,6 @@ export default function AdminDatabaseIndex({ tables }: { tables: string[] }) {
3225

3326
setLoading(true)
3427
setError(null)
35-
setExecuteMsg(null)
3628
setResult(null)
3729

3830
try {
@@ -54,57 +46,20 @@ export default function AdminDatabaseIndex({ tables }: { tables: string[] }) {
5446
}
5547
}
5648

57-
async function runExecute() {
58-
if (!sql.trim()) return
59-
if (!confirm('This will execute a write operation. Continue?')) return
60-
61-
setLoading(true)
62-
setError(null)
63-
setExecuteMsg(null)
64-
setResult(null)
65-
66-
try {
67-
const res = await fetch('/admin/database/execute', {
68-
method: 'POST',
69-
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
70-
body: JSON.stringify({ sql }),
71-
})
72-
const data: ExecuteResult | ErrorResult = await res.json()
73-
if ('error' in data) {
74-
setError(data.error)
75-
} else {
76-
setExecuteMsg(`${data.message} (${data.affected_rows} rows affected)`)
77-
}
78-
} catch (e) {
79-
setError('Request failed')
80-
} finally {
81-
setLoading(false)
82-
}
83-
}
84-
8549
function selectTable(table: string) {
8650
setSelectedTable(table)
8751
const query = `SELECT * FROM ${table} LIMIT 50`
8852
setSql(query)
8953
runQuery(query)
9054
}
9155

92-
function deleteRow(table: string, primaryKey: string, id: string | number | boolean | null) {
93-
if (!confirm(`Delete row with ${primaryKey} = ${id} from ${table}?`)) return
94-
const query = `DELETE FROM ${table} WHERE ${primaryKey} = ${typeof id === 'string' ? `'${id.replace(/'/g, "''")}'` : id}`
95-
setSql(query)
96-
runExecute()
97-
}
98-
9956
function handleKeyDown(e: React.KeyboardEvent) {
10057
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
10158
e.preventDefault()
10259
runQuery()
10360
}
10461
}
10562

106-
const idColumnIndex = result?.columns.findIndex(c => c === 'id') ?? -1
107-
10863
return (
10964
<div className="p-12 max-w-[1600px] mx-auto">
11065
<div className="flex items-center justify-between mb-8">
@@ -134,7 +89,6 @@ export default function AdminDatabaseIndex({ tables }: { tables: string[] }) {
13489
<div className="flex-1 min-w-0 space-y-4">
13590
<div className="ghost-border bg-[#1c1b1b] p-4">
13691
<textarea
137-
ref={textareaRef}
13892
value={sql}
13993
onChange={(e) => setSql(e.target.value)}
14094
onKeyDown={handleKeyDown}
@@ -150,13 +104,6 @@ export default function AdminDatabaseIndex({ tables }: { tables: string[] }) {
150104
>
151105
{loading ? 'Running...' : 'Run Query'}
152106
</button>
153-
<button
154-
onClick={runExecute}
155-
disabled={loading}
156-
className="ghost-border bg-[#1c1b1b] text-stone-400 hover:bg-[#2a2a2a] px-6 py-3 font-bold uppercase tracking-wider text-xs cursor-pointer disabled:opacity-50"
157-
>
158-
Execute (Write)
159-
</button>
160107
<span className="text-stone-600 text-xs self-center ml-2">⌘+Enter to run</span>
161108
</div>
162109
</div>
@@ -167,12 +114,6 @@ export default function AdminDatabaseIndex({ tables }: { tables: string[] }) {
167114
</div>
168115
)}
169116

170-
{executeMsg && (
171-
<div className="border border-emerald-500/20 bg-emerald-500/5 px-5 py-4 text-emerald-400 text-sm">
172-
{executeMsg}
173-
</div>
174-
)}
175-
176117
{result && (
177118
<div>
178119
<div className="flex items-center justify-between mb-2">
@@ -187,9 +128,6 @@ export default function AdminDatabaseIndex({ tables }: { tables: string[] }) {
187128
{col}
188129
</th>
189130
))}
190-
{selectedTable && idColumnIndex >= 0 && (
191-
<th className="text-right px-4 py-3 text-[10px] uppercase tracking-[0.2em] font-bold text-stone-600">Actions</th>
192-
)}
193131
</tr>
194132
</thead>
195133
<tbody>
@@ -200,16 +138,6 @@ export default function AdminDatabaseIndex({ tables }: { tables: string[] }) {
200138
{cell === null ? <span className="text-stone-600 italic">NULL</span> : String(cell)}
201139
</td>
202140
))}
203-
{selectedTable && idColumnIndex >= 0 && (
204-
<td className="px-4 py-3 text-right">
205-
<button
206-
onClick={() => deleteRow(selectedTable, 'id', row[idColumnIndex])}
207-
className="text-red-400/50 hover:text-red-400 transition-colors cursor-pointer"
208-
>
209-
<span className="material-symbols-outlined text-lg">delete</span>
210-
</button>
211-
</td>
212-
)}
213141
</tr>
214142
))}
215143
</tbody>

config/routes.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,6 @@
131131
get "audit_log/:id" => "audit_log#show", as: :audit_log_entry
132132
get "database" => "database#index", as: :database
133133
post "database/query" => "database#query"
134-
post "database/execute" => "database#execute"
135134
resources :support_tickets, only: [ :index, :show, :destroy ], path: "support" do
136135
member do
137136
post :reply

0 commit comments

Comments
 (0)