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