🍯 Glaze

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")
}