package main import "base:runtime" import "core:flags" import "core:fmt" import "core:mem" import "core:net" import "core:os" import "core:path/slashpath" import "core:strings" import "core:time" import cmark "vendor:commonmark" import "lib:html/template" import "lib:net/http" import "lib:net/http/router" Options :: struct { listen: net.Endpoint `usage:"Address to listen on. Defaults to 127.0.0.1:8080"`, user: string `usage:"Limit to repos by user"`, soft_serve_path: string `usage:"Soft-Serve base path. Defaults to /var/lib/soft-serve"`, build_hash: string `usage:"Sets the build hash"`, } endpoint_handler :: proc( data: rawptr, data_type: typeid, unparsed_value: string, args_tag: string, ) -> ( error: string, handled: bool, alloc_error: runtime.Allocator_Error, ) { if data_type == net.Endpoint { handled = true ptr := cast(^net.Endpoint)data addr, ok := net.parse_endpoint(unparsed_value) if !ok { error = "listen must be an ip:port combination" } ptr^ = addr } return } Breadcrumb :: struct { path: string, name: string, } // NOTE: /r/repo_name/type/repo_ref/repo_object_path make_breadcrumbs :: proc(repo_name, type, repo_ref, repo_object_path: string) -> []Breadcrumb { num_parts := strings.count(repo_object_path, "/") + 2 breadcrumbs := make([]Breadcrumb, num_parts) breadcrumbs[0].path = strings.join({"", "r", repo_name, "tree", repo_ref}, "/") breadcrumbs[0].name = repo_name target := repo_object_path last_i: int for i in 1 ..< num_parts { slash_i := strings.index(target, "/") if slash_i < 0 { break } last_i += slash_i + 1 breadcrumbs[i].path = strings.join({breadcrumbs[0].path, repo_object_path[:last_i]}, "/") breadcrumbs[i].name = target[:slash_i] target = target[slash_i + 1:] } breadcrumbs[num_parts - 1].path = strings.join({"", "r", repo_name, type, repo_ref, repo_object_path}, "/") breadcrumbs[num_parts - 1].name = target return breadcrumbs } destroy_breadcrumbs :: proc(breadcrumbs: ^[]Breadcrumb) { for b in breadcrumbs { delete(b.path) } delete(breadcrumbs^) } Day :: time.Hour * 24 Week :: Day * 7 Month :: Week * 4 Year :: Day * 365 time_since :: proc(updated_at: time.Time) -> string { num: int unit: string time_diff := time.diff(updated_at, time.now()) switch { case time_diff < time.Minute: num = int(time_diff / time.Second) unit = "seconds" if num > 1 else "second" case time_diff < time.Hour: num = int(time_diff / time.Minute) unit = "minutes" if num > 1 else "minute" case time_diff < Day: num = int(time_diff / time.Hour) unit = "hours" if num > 1 else "hour" case time_diff < Week: num = int(time_diff / Day) unit = "days" if num > 1 else "day" case time_diff < Month: num = int(time_diff / Week) unit = "weeks" if num > 1 else "week" case time_diff < Year: num = int(time_diff / Month) unit = "months" if num > 1 else "month" case: return strings.clone("A long time ago...") } b: strings.Builder strings.builder_init(&b) defer strings.builder_destroy(&b) strings.write_string(&b, "Updated ") strings.write_int(&b, num) strings.write_string(&b, " ") strings.write_string(&b, unit) strings.write_string(&b, " ago") return strings.clone(strings.to_string(b)) } request_logger :: proc( req: router.Request, res: ^router.Response, ctx: rawptr, next: router.Route_Handler, next_ctx: rawptr, ) { fmt.printf("-> %s %s\n", req.method, req.target) next(req, res, next_ctx) fmt.printf("<- %v\n", res.code) } App_Context :: struct { repo_map: ^Repo_Map, build_hash: string, } index_handler :: proc(req: router.Request, res: ^router.Response, ctx: rawptr) { app_ctx := (^App_Context)(ctx) b: strings.Builder strings.builder_init(&b) defer strings.builder_destroy(&b) Repo_Data :: struct { name: string, description: string, updated_at: string, } repos := make([]Repo_Data, len(app_ctx.repo_map.repos)) defer delete(repos) for repo, i in app_ctx.repo_map.repos { r := &repos[i] r.name = repo.name r.description = repo.description r.updated_at = time_since(repo.head.updated_at) } defer { for r in &repos { delete(r.updated_at) } } tpl: template.Template if !template.compile(&tpl, "web/index.tpl") { fmt.eprintf("couldn't index template\n") http.respond(res, .InternalServerError, nil) return } defer template.destroy(&tpl) template.render(&tpl, &b, struct { repos: []Repo_Data, build_hash: string, }{repos = repos, build_hash = app_ctx.build_hash}) http.respond(res, .Ok, strings.to_string(b)) } raw_handler :: proc(req: router.Request, res: ^router.Response, ctx: rawptr) { app_ctx := (^App_Context)(ctx) ok: bool repo_name: string repo_ref: string repo_object_path: string repo_name, ok = req.params["repo"] if !ok { http.respond(res, .NotFound) return } repo_ref, ok = req.params["ref"] if !ok { repo_ref = "main" } repo_object_path = req.params["path"] full_repo_ref := strings.join({"refs", "heads", repo_ref}, "/") defer delete(full_repo_ref) b: strings.Builder strings.builder_init(&b) defer strings.builder_destroy(&b) blob: []u8 blob, ok = get_raw_blob(app_ctx.repo_map, repo_name, full_repo_ref, repo_object_path) if !ok { http.respond(res, .NotFound) return } defer delete(blob) // TODO: mime support http.headers_add(&res.headers, "content-type", "text/plain; charset=utf-8") http.respond(res, .Ok, blob) return } blob_handler :: proc(req: router.Request, res: ^router.Response, ctx: rawptr) { app_ctx := (^App_Context)(ctx) ok: bool repo_name: string repo_ref: string repo_object_path: string repo_name, ok = req.params["repo"] if !ok { http.respond(res, .NotFound) return } repo_ref, ok = req.params["ref"] if !ok { repo_ref = "main" } repo_object_path = req.params["path"] full_repo_ref := strings.join({"refs", "heads", repo_ref}, "/") defer delete(full_repo_ref) b: strings.Builder strings.builder_init(&b) defer strings.builder_destroy(&b) blob: []u8 blob, ok = get_raw_blob(app_ctx.repo_map, repo_name, full_repo_ref, repo_object_path) if !ok { http.respond(res, .NotFound) return } defer delete(blob) // TODO: mime support breadcrumbs := make_breadcrumbs(repo_name, "blob", repo_ref, repo_object_path) defer destroy_breadcrumbs(&breadcrumbs) tpl: template.Template if !template.compile(&tpl, "web/blob.tpl") { fmt.eprintf("couldn't index template\n") http.respond(res, .InternalServerError, nil) return } defer template.destroy(&tpl) template.render(&tpl, &b, struct { breadcrumbs: []Breadcrumb, blob: string, blob_filename: string, repo_name: string, repo_ref: string, canonical_url: string, build_hash: string, } { breadcrumbs = breadcrumbs, blob = string(blob), blob_filename = slashpath.base(repo_object_path), repo_name = repo_name, repo_ref = repo_ref if repo_ref != "" else "main", canonical_url = req.target, build_hash = app_ctx.build_hash, }) http.respond(res, .Ok, strings.to_string(b)) return } tree_handler :: proc(req: router.Request, res: ^router.Response, ctx: rawptr) { app_ctx := (^App_Context)(ctx) ok: bool repo_name: string repo_ref: string repo_object_path: string repo_name, ok = req.params["repo"] if !ok { http.respond(res, .NotFound) return } repo_ref, ok = req.params["ref"] if !ok { repo_ref = "main" } repo_object_path = req.params["path"] full_repo_ref := strings.join({"refs", "heads", repo_ref}, "/") defer delete(full_repo_ref) b: strings.Builder strings.builder_init(&b) defer strings.builder_destroy(&b) repo: Repo repo, ok = repo_map_get_repo(app_ctx.repo_map, repo_name) if !ok { http.respond(res, .NotFound) return } repo_tree: []Repo_Tree_Entry repo_tree, ok = get_repo_tree(app_ctx.repo_map, repo.name, full_repo_ref, repo_object_path) if !ok { repo_tree, ok = get_repo_tree(app_ctx.repo_map, repo.name, repo_ref, repo_object_path) if !ok { http.respond(res, .NotFound) return } } defer for entry in repo_tree { delete(entry.name) } defer delete(repo_tree) breadcrumbs := make_breadcrumbs(repo_name, "blob", repo_ref, repo_object_path) defer destroy_breadcrumbs(&breadcrumbs) Repo_Object :: struct { target_path: string, raw_target_path: string, object_name: string, } repo_dirs := make([dynamic]Repo_Object, 0, len(repo_tree)) repo_files := make([dynamic]Repo_Object, 0, len(repo_tree)) defer { for r in repo_dirs { delete(r.target_path) delete(r.raw_target_path) } for r in repo_files { delete(r.target_path) delete(r.raw_target_path) } delete(repo_dirs) delete(repo_files) } path_b: strings.Builder strings.builder_init(&path_b) defer strings.builder_destroy(&path_b) if repo_object_path != "" { strings.write_string(&path_b, "/r/") strings.write_string(&path_b, repo_name) strings.write_rune(&path_b, '/') strings.write_string(&path_b, "tree") strings.write_rune(&path_b, '/') strings.write_string(&path_b, repo_ref if repo_ref != "" else "main") strings.write_rune(&path_b, '/') last_slash := strings.last_index(repo_object_path, "/") if last_slash != -1 { strings.write_string(&path_b, repo_object_path[:last_slash]) } append(&repo_dirs, Repo_Object{target_path = strings.clone(strings.to_string(path_b)), object_name = ".."}) } // TODO: Break out and clean up links for object in repo_tree { strings.builder_reset(&path_b) strings.write_string(&path_b, "/r/") strings.write_string(&path_b, repo_name) strings.write_rune(&path_b, '/') strings.write_string(&path_b, "tree" if object.type == .Dir else "blob") strings.write_rune(&path_b, '/') strings.write_string(&path_b, repo_ref if repo_ref != "" else "main") strings.write_rune(&path_b, '/') if repo_object_path != "" { strings.write_string(&path_b, repo_object_path) strings.write_rune(&path_b, '/') } strings.write_string(&path_b, object.name) target_path := strings.clone(strings.to_string(path_b)) strings.builder_reset(&path_b) strings.write_string(&path_b, "/r/") strings.write_string(&path_b, repo_name) strings.write_string(&path_b, "/raw/") strings.write_string(&path_b, repo_ref if repo_ref != "" else "main") strings.write_rune(&path_b, '/') if repo_object_path != "" { strings.write_string(&path_b, repo_object_path) strings.write_rune(&path_b, '/') } strings.write_string(&path_b, object.name) raw_target_path := strings.clone(strings.to_string(path_b)) r := Repo_Object { target_path = target_path, raw_target_path = raw_target_path, object_name = object.name, } if object.type == .Dir { append(&repo_dirs, r) } else { append(&repo_files, r) } } readme: string if repo_object_path == "" { for entry in repo_tree { name := strings.to_lower(entry.name) defer delete(name) if name == "readme.md" { blob: []u8 blob, ok = get_raw_blob(app_ctx.repo_map, repo_name, full_repo_ref, entry.name) if !ok { break } defer delete(blob) root := cmark.parse_document(raw_data(blob), len(blob), cmark.DEFAULT_OPTIONS) defer cmark.node_free(root) html := cmark.render_html(root, cmark.DEFAULT_OPTIONS) defer cmark.free(html) readme = strings.clone_from_cstring(html) break } } } defer { if readme != "" { delete(readme) } } tpl: template.Template if !template.compile(&tpl, "web/tree.tpl") { fmt.eprintf("couldn't index template\n") http.respond(res, .InternalServerError, nil) return } defer template.destroy(&tpl) has_mascot: bool if repo_object_path == "" && repo_name == "glaze" { has_mascot = true } updated_at := time_since(repo.head.updated_at) defer delete(updated_at) template.render(&tpl, &b, struct { breadcrumbs: []Breadcrumb, head_summary: string, updated_at: string, repo_files: []Repo_Object, repo_dirs: []Repo_Object, mascot: ^bool, readme: ^string, blob: string, repo_name: string, repo_tree: string, repo_ref: string, canonical_url: string, build_hash: string, } { breadcrumbs = breadcrumbs, head_summary = repo.head.summary, updated_at = updated_at, repo_files = repo_files[:], repo_dirs = repo_dirs[:], mascot = &has_mascot if has_mascot else nil, readme = &readme if readme != "" else nil, repo_name = repo_name, repo_tree = repo_object_path if repo_object_path != "" else "/", repo_ref = repo_ref if repo_ref != "" else "main", canonical_url = req.target, build_hash = app_ctx.build_hash, }) http.respond(res, .Ok, strings.to_string(b)) } run_app :: proc() { opt: Options flags.register_type_setter(endpoint_handler) flags.parse_or_exit(&opt, os.args, .Unix) if opt.listen.address == nil { opt.listen.address = net.IP4_Loopback opt.listen.port = 8080 } if opt.soft_serve_path == "" { opt.soft_serve_path = "/var/lib/soft-serve" } repo_map: Repo_Map repo_map_init(&repo_map, opt.soft_serve_path, opt.user) defer repo_map_destroy(&repo_map) r := router.create() defer router.destroy(&r) router.static(&r, "/static", "./static") app_context := App_Context { repo_map = &repo_map, build_hash = opt.build_hash, } router.get(&r, "/", index_handler, .Exact, &app_context) router.get(&r, "/r/:repo/raw/:ref/*path", raw_handler, .Pattern, &app_context) router.get(&r, "/r/:repo/blob/:ref/*path", blob_handler, .Pattern, &app_context) router.get(&r, "/r/:repo", tree_handler, .Pattern, &app_context) router.get(&r, "/r/:repo/tree/:ref/*path", tree_handler, .Pattern, &app_context) router.get(&r, "/healthcheck", proc(req: router.Request, res: ^router.Response, ctx: rawptr) { http.respond(res, .Ok, "ok") }, .Exact) router.use(&r, request_logger) router.use(&r, repo_map_updater, &repo_map) http.serve(opt.listen, router.handle(&r)) } main :: proc() { when ODIN_DEBUG { track: mem.Tracking_Allocator mem.tracking_allocator_init(&track, context.allocator) context.allocator = mem.tracking_allocator(&track) } run_app() when ODIN_DEBUG { for _, leak in track.allocation_map { fmt.printf("%v leaked %m\n", leak.location, leak.size) } mem.tracking_allocator_destroy(&track) } fmt.printf("BYE\n") }