v / cmd / tools / vbug-report.v
315 lines · 288 sloc · 7.91 KB · a72a784ee3b66252e3f32b7bb52815e9dda96d67
Raw
1module main
2
3import db.sqlite
4import flag
5import json
6import os
7import rand
8import time
9import vbugreport
10import veb
11
12const default_port = 8080
13const max_report_body_bytes = 256 * 1024
14
15struct ReportLine {
16pub:
17 line int
18 text string
19}
20
21struct BugReport {
22pub:
23 kind string
24 v_version string
25 target_os string
26 target_backend string
27 ccompiler string
28 c_error string
29 c_file string
30 c_line int
31 c_context []ReportLine
32 v_file string
33 v_line int
34 v_context []ReportLine
35}
36
37struct CreateBugReportResponse {
38pub:
39 id string
40 delete_url string
41 delete_token string
42 message string
43}
44
45struct DeleteBugReportResponse {
46pub:
47 id string
48 deleted bool
49 message string
50}
51
52struct ServerConfig {
53 host string
54 port int
55 db_path string
56 public_url string
57}
58
59pub struct App {
60pub:
61 public_url string
62pub mut:
63 db sqlite.DB
64}
65
66pub struct Context {
67 veb.Context
68}
69
70fn args_without_command() []string {
71 args := os.args#[1..]
72 if args.len > 0 && args[0] == 'bug-report' {
73 return args[1..]
74 }
75 return args
76}
77
78fn db_path_from_env() string {
79 db_path := os.getenv('V_BUG_REPORT_DB')
80 if db_path != '' {
81 return db_path
82 }
83 report_dir := os.getenv('V_BUG_REPORT_DIR')
84 if report_dir != '' {
85 return os.join_path(report_dir, 'bug_reports.sqlite')
86 }
87 return os.join_path(os.getwd(), 'bug_reports.sqlite')
88}
89
90fn report_host_from_env() string {
91 host := os.getenv('V_BUG_REPORT_HOST')
92 if host != '' {
93 return host
94 }
95 return 'localhost'
96}
97
98fn report_port_from_env() int {
99 bug_report_port := os.getenv('V_BUG_REPORT_PORT')
100 if bug_report_port != '' {
101 return bug_report_port.int()
102 }
103 port := os.getenv('PORT')
104 if port != '' {
105 return port.int()
106 }
107 return default_port
108}
109
110fn public_url_from_config(flag_url string, host string, port int) string {
111 url := if flag_url != '' { flag_url } else { os.getenv('V_BUG_REPORT_PUBLIC_URL') }
112 if url != '' {
113 return url.trim_space().trim_right('/')
114 }
115 public_host := if host == '' || host == '0.0.0.0' { 'localhost' } else { host }
116 return 'http://${public_host}:${port}/bug-report'
117}
118
119fn config_from_args() !ServerConfig {
120 mut fp := flag.new_flag_parser(args_without_command())
121 fp.application('v bug-report')
122 fp.version('0.0.1')
123 fp.description('Run the V compiler bug report receiver.')
124 fp.arguments_description('')
125 show_help := fp.bool('help', `h`, false, 'Show this help screen.')
126 host := fp.string('host', 0, report_host_from_env(), 'Host to listen on.')
127 port := fp.int('port', `p`, report_port_from_env(), 'Port to listen on.')
128 db_path := fp.string('db', 0, db_path_from_env(), 'SQLite database path.')
129 public_url_arg := fp.string('public-url', 0, '', 'Public /bug-report endpoint URL.')
130 if show_help {
131 println(fp.usage())
132 exit(0)
133 }
134 remaining := fp.finalize()!
135 if remaining.len > 0 {
136 return error('unexpected arguments: ${remaining.join(' ')}')
137 }
138 return ServerConfig{
139 host: host
140 port: port
141 db_path: db_path
142 public_url: public_url_from_config(public_url_arg, host, port)
143 }
144}
145
146fn safe_id(id string) bool {
147 if id.len < 8 || id.len > 80 {
148 return false
149 }
150 for ch in id {
151 if !ch.is_letter() && !ch.is_digit() && ch != `-` && ch != `_` {
152 return false
153 }
154 }
155 return true
156}
157
158fn new_report_id() string {
159 return rand.uuid_v4()
160}
161
162fn (app App) delete_url(id string, token string) string {
163 return '${app.public_url}/${id}?token=${token}'
164}
165
166fn delete_token_from_request(ctx Context) string {
167 if token := ctx.query['token'] {
168 return token
169 }
170 return ctx.get_custom_header('X-Delete-Token') or { '' }
171}
172
173fn ensure_db_parent(db_path string) ! {
174 if db_path == ':memory:' || db_path.starts_with('file:') {
175 return
176 }
177 parent_dir := os.dir(db_path)
178 if parent_dir != '' && parent_dir != '.' {
179 os.mkdir_all(parent_dir)!
180 }
181}
182
183fn connect_db(db_path string) !sqlite.DB {
184 ensure_db_parent(db_path)!
185 mut db := sqlite.connect_full(db_path, [.readwrite, .create, .fullmutex], '')!
186 db.busy_timeout(3000)
187 return db
188}
189
190fn init_db(db sqlite.DB) ! {
191 db.exec("create table if not exists bug_reports (
192 id text primary key,
193 delete_token text not null,
194 created_at text not null,
195 remote_ip text not null,
196 user_agent text not null,
197 c_file_name text not null,
198 target_os text not null,
199 ccompiler text not null,
200 error_string text not null,
201 lines text not null,
202 v_lines text not null default ''
203 )")!
204 // Add columns that were introduced after the table already existed, so older
205 // databases pick them up without losing their stored reports.
206 ensure_column(db, 'bug_reports', 'v_lines', "text not null default ''")!
207 db.exec('create index if not exists idx_bug_reports_created_at
208 on bug_reports(created_at)')!
209}
210
211// ensure_column adds `column` to `table` when it is not present yet. SQLite has no
212// `add column if not exists`, so the existing columns are inspected first.
213fn ensure_column(db sqlite.DB, table string, column string, definition string) ! {
214 rows := db.exec('pragma table_info(${table})')!
215 for row in rows {
216 // pragma table_info columns are: cid, name, type, notnull, dflt_value, pk
217 if row.vals.len > 1 && row.vals[1] == column {
218 return
219 }
220 }
221 db.exec('alter table ${table} add column ${column} ${definition}')!
222}
223
224// create stores a compiler bug report and returns its deletion token.
225@['/bug-report'; post]
226pub fn (mut app App) create(mut ctx Context) veb.Result {
227 if ctx.req.data.len == 0 {
228 return ctx.request_error('empty report body')
229 }
230 if ctx.req.data.len > max_report_body_bytes {
231 ctx.res.set_status(.bad_request)
232 return ctx.text('report body is too large')
233 }
234 report := json.decode(BugReport, ctx.req.data) or {
235 return ctx.request_error('invalid report JSON: ${err}')
236 }
237 if report.kind != 'v-c-compiler-error' {
238 return ctx.request_error('unsupported report kind')
239 }
240 stored_report := vbugreport.new_stored_c_error_report(report.c_file, report.target_os,
241 report.ccompiler, report.c_error, report.c_context.map(it.text),
242 report.v_context.map(it.text))
243 id := new_report_id()
244 delete_token := rand.uuid_v4()
245 app.db.exec_param_many('insert into bug_reports (
246 id, delete_token, created_at, remote_ip, user_agent,
247 c_file_name, target_os, ccompiler, error_string, lines, v_lines
248 ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [
249 id,
250 delete_token,
251 time.utc().format_rfc3339(),
252 ctx.ip(),
253 ctx.user_agent(),
254 stored_report.c_file_name,
255 stored_report.target_os,
256 stored_report.ccompiler,
257 stored_report.error_string,
258 stored_report.lines,
259 stored_report.v_lines,
260 ]) or { return ctx.server_error('could not store report') }
261 return ctx.json(CreateBugReportResponse{
262 id: id
263 delete_url: app.delete_url(id, delete_token)
264 delete_token: delete_token
265 message: 'created'
266 })
267}
268
269// delete removes a compiler bug report when the deletion token matches.
270@['/bug-report/:id'; delete]
271pub fn (mut app App) delete(mut ctx Context, id string) veb.Result {
272 if !safe_id(id) {
273 return ctx.request_error('invalid report id')
274 }
275 rows := app.db.exec_param('select delete_token from bug_reports where id = ?', id) or {
276 return ctx.server_error('could not read report')
277 }
278 if rows.len == 0 {
279 return ctx.not_found()
280 }
281 if delete_token_from_request(ctx) != rows[0].vals[0] {
282 ctx.res.set_status(.forbidden)
283 return ctx.text('invalid delete token')
284 }
285 app.db.exec_param('delete from bug_reports where id = ?', id) or {
286 return ctx.server_error('could not delete report')
287 }
288 return ctx.json(DeleteBugReportResponse{
289 id: id
290 deleted: true
291 message: 'deleted'
292 })
293}
294
295// healthz returns a basic readiness response for local checks.
296@['/healthz'; get]
297pub fn (mut app App) healthz(mut ctx Context) veb.Result {
298 return ctx.text('ok')
299}
300
301fn main() {
302 config := config_from_args() or {
303 eprintln('v bug-report: ${err}')
304 exit(1)
305 }
306 mut db := connect_db(config.db_path) or { panic(err) }
307 init_db(db) or { panic(err) }
308 mut app := &App{
309 public_url: config.public_url
310 db: db
311 }
312 veb.run_at[App, Context](mut app, host: config.host, port: config.port, family: .ip) or {
313 panic(err)
314 }
315}
316