| 1 | module main |
| 2 | |
| 3 | import db.sqlite |
| 4 | import flag |
| 5 | import json |
| 6 | import os |
| 7 | import rand |
| 8 | import time |
| 9 | import vbugreport |
| 10 | import veb |
| 11 | |
| 12 | const default_port = 8080 |
| 13 | const max_report_body_bytes = 256 * 1024 |
| 14 | |
| 15 | struct ReportLine { |
| 16 | pub: |
| 17 | line int |
| 18 | text string |
| 19 | } |
| 20 | |
| 21 | struct BugReport { |
| 22 | pub: |
| 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 | |
| 37 | struct CreateBugReportResponse { |
| 38 | pub: |
| 39 | id string |
| 40 | delete_url string |
| 41 | delete_token string |
| 42 | message string |
| 43 | } |
| 44 | |
| 45 | struct DeleteBugReportResponse { |
| 46 | pub: |
| 47 | id string |
| 48 | deleted bool |
| 49 | message string |
| 50 | } |
| 51 | |
| 52 | struct ServerConfig { |
| 53 | host string |
| 54 | port int |
| 55 | db_path string |
| 56 | public_url string |
| 57 | } |
| 58 | |
| 59 | pub struct App { |
| 60 | pub: |
| 61 | public_url string |
| 62 | pub mut: |
| 63 | db sqlite.DB |
| 64 | } |
| 65 | |
| 66 | pub struct Context { |
| 67 | veb.Context |
| 68 | } |
| 69 | |
| 70 | fn 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 | |
| 78 | fn 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 | |
| 90 | fn 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 | |
| 98 | fn 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 | |
| 110 | fn 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 | |
| 119 | fn 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 | |
| 146 | fn 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 | |
| 158 | fn new_report_id() string { |
| 159 | return rand.uuid_v4() |
| 160 | } |
| 161 | |
| 162 | fn (app App) delete_url(id string, token string) string { |
| 163 | return '${app.public_url}/${id}?token=${token}' |
| 164 | } |
| 165 | |
| 166 | fn 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 | |
| 173 | fn 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 | |
| 183 | fn 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 | |
| 190 | fn 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. |
| 213 | fn 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] |
| 226 | pub 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] |
| 271 | pub 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] |
| 297 | pub fn (mut app App) healthz(mut ctx Context) veb.Result { |
| 298 | return ctx.text('ok') |
| 299 | } |
| 300 | |
| 301 | fn 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 | |