Goit

Simple and lightweight Git web server
git clone http://git.omkov.net/Goit
Log | Tree | Refs | README | Download

AuthorJakob Wakeling <[email protected]>
Date2024-07-06 11:32:05
Commita0ac27c1150c392bcc36911016fd12b2ddac0d1f
Parent2954edd7dff67c4d435e513612e741d9483bba35

Add Git blame page for files

Diffstat

M README.md | 2 +-
M go.mod | 16 ++++++++--------
M go.sum | 40 ++++++++++++++++++++--------------------
A res/repo/blame.html | 31 +++++++++++++++++++++++++++++++
M res/repo/file.html | 4 +++-
M res/repo/tree.html | 4 +++-
M res/res.go | 3 +++
M src/goit/git.go | 15 +++++++++++++++
M src/goit/http.go | 1 +
M src/main.go | 4 ++++
A src/repo/blame.go | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M src/repo/file.go | 65 ++++++++++++++++++++++++++++++++---------------------------------
M src/repo/highlight.go | 4 ++--
M src/repo/tree.go | 16 +++++++++++++---
M src/util/log.go | 16 +++++++++++++++-

15 files changed, 329 insertions, 70 deletions

diff --git a/README.md b/README.md
index 0510319..64d9832 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Note that at present, compatibility between updates is not guaranteed.
 - Git SSH protocol (planned)
 - Repository log, tree, refs, and commit viewers
 - File viewer with syntax highlighting
-- File log, blame (planned), and raw views
+- File log, blame, and raw views
 - Public and private repositories
 - Read and write permissions for non owners (planned)
 - Repository importing and mirroring
diff --git a/go.mod b/go.mod
index 80ac4e3..cef3f9c 100644
--- a/go.mod
+++ b/go.mod
@@ -4,22 +4,22 @@ go 1.22
 
 require (
 	github.com/alecthomas/chroma v0.10.0
-	github.com/buildkite/terminal-to-html/v3 v3.11.0
+	github.com/buildkite/terminal-to-html/v3 v3.13.0
 	github.com/dustin/go-humanize v1.0.1
-	github.com/go-chi/chi/v5 v5.0.12
+	github.com/go-chi/chi/v5 v5.1.0
 	github.com/go-git/go-git/v5 v5.12.0
 	github.com/gorilla/csrf v1.7.2
 	github.com/mattn/go-sqlite3 v1.14.22
-	golang.org/x/crypto v0.22.0
+	golang.org/x/crypto v0.25.0
 )
 
 require (
 	dario.cat/mergo v1.0.0 // indirect
 	github.com/Microsoft/go-winio v0.6.2 // indirect
 	github.com/ProtonMail/go-crypto v1.0.0 // indirect
-	github.com/cloudflare/circl v1.3.7 // indirect
-	github.com/cyphar/filepath-securejoin v0.2.4 // indirect
-	github.com/dlclark/regexp2 v1.11.0 // indirect
+	github.com/cloudflare/circl v1.3.9 // indirect
+	github.com/cyphar/filepath-securejoin v0.2.5 // indirect
+	github.com/dlclark/regexp2 v1.11.1 // indirect
 	github.com/emirpasic/gods v1.18.1 // indirect
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 	github.com/go-git/go-billy/v5 v5.5.0 // indirect
@@ -31,7 +31,7 @@ require (
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
 	github.com/skeema/knownhosts v1.2.2 // indirect
 	github.com/xanzy/ssh-agent v0.3.3 // indirect
-	golang.org/x/net v0.24.0 // indirect
-	golang.org/x/sys v0.19.0 // indirect
+	golang.org/x/net v0.27.0 // indirect
+	golang.org/x/sys v0.22.0 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 )
diff --git a/go.sum b/go.sum
index 0dc51e1..1af3e22 100644
--- a/go.sum
+++ b/go.sum
@@ -11,20 +11,20 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
-github.com/buildkite/terminal-to-html/v3 v3.11.0 h1:wMTpKgR61lqmxMz1FKjCaW5mq6DqeEgFZdJ+SU4hP30=
-github.com/buildkite/terminal-to-html/v3 v3.11.0/go.mod h1:8JACDet3vmvWLsL4IBobweQYtf19W5J+EKM3LEE1c+4=
+github.com/buildkite/terminal-to-html/v3 v3.13.0 h1:TBRfvqZWoIpxxiiM9rdIn2bbI7pwxBjlQf8/cbLJnss=
+github.com/buildkite/terminal-to-html/v3 v3.13.0/go.mod h1:33sojHDaRBSMwwkKXPEkb5Uc7LKF79rWGurL3ei/GX0=
 github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
 github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
-github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
-github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
-github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
-github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
+github.com/cloudflare/circl v1.3.9 h1:QFrlgFYf2Qpi8bSpVPK1HBvWpx16v/1TZivyo7pGuBE=
+github.com/cloudflare/circl v1.3.9/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
+github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
+github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
-github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
-github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dlclark/regexp2 v1.11.1 h1:CJs78ewKXO9PuNf6Xwlw6eibMadBkXTRpOeUdv+IcWM=
+github.com/dlclark/regexp2 v1.11.1/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
@@ -33,8 +33,8 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc
 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
 github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
 github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
-github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
-github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
+github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
 github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
@@ -95,8 +95,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
-golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
-golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
+golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
+golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -106,8 +106,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
-golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
-golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
+golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
+golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -124,15 +124,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
-golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
-golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
-golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
+golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
+golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -140,8 +140,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
diff --git a/res/repo/blame.html b/res/repo/blame.html
new file mode 100644
index 0000000..c98a1f7
--- /dev/null
+++ b/res/repo/blame.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>{{template "base/head" .}}</head>
+	<body>
+		<header>
+			{{template "repo/header" .}}<hr>
+			{{.PathHTML}} ({{.LineC}}, {{.Size}}) {{.Mode}}
+			<a href="/{{.Name}}/file/{{.Path}}">file</a>
+			<a href="/{{.Name}}/download/{{.Path}}">download</a>
+		</header><hr>
+		<main>
+			<table class="highlight-row">
+				{{if .Blines}}
+					{{range $i, $l := .Blines}}
+						<tr>
+							<td><a href="/{{$.Name}}/commit/{{.Hash}}">{{.ShortHash}}</a></td>
+							<td>{{.Author}}</td>
+							<td>{{.Date}}</td>
+							<td class="lnum" style="text-align: right;">
+								<pre><a id="{{$i}}" href="#{{$i}}">{{$i}}</a></pre>
+							</td>
+							<td class="line"><pre>{{.LineHTML}}</pre></td>
+						</tr>
+					{{end}}
+				{{else}}
+					<tr><td>Binary file</td></tr>
+				{{end}}
+			</table>
+		</main>
+	</body>
+</html>
diff --git a/res/repo/file.html b/res/repo/file.html
index 5009c56..7754abb 100644
--- a/res/repo/file.html
+++ b/res/repo/file.html
@@ -4,7 +4,9 @@
 	<body>
 		<header>
 			{{template "repo/header" .}}<hr>
-			{{.HtmlPath}} ({{.LineC}}, {{.Size}}) {{.Mode}} <a href="/{{.Name}}/download/{{.Path}}">download</a>
+			{{.HtmlPath}} ({{.LineC}}, {{.Size}}) {{.Mode}}
+			{{if .IsText}}<a href="/{{.Name}}/blame/{{.Path}}">blame</a>{{end}}
+			<a href="/{{.Name}}/download/{{.Path}}">download</a>
 		</header><hr>
 		<main>
 			<table>
diff --git a/res/repo/tree.html b/res/repo/tree.html
index 809fc4a..023833c 100644
--- a/res/repo/tree.html
+++ b/res/repo/tree.html
@@ -27,7 +27,9 @@
 									{{if .RawPath}}
 										<a href="/{{$.Name}}/log/{{.RawPath}}">log</a>
 										{{if .IsFile}}
-											blame
+											{{if .IsText}}
+												<a href="/{{$.Name}}/blame/{{.RawPath}}">blame</a>
+											{{end}}
 											<a href="/{{$.Name}}/raw/{{.RawPath}}">raw</a>
 										{{end}}
 										<a href="/{{$.Name}}/download/{{.RawPath}}">download</a>
diff --git a/res/res.go b/res/res.go
index af125bd..772f4e6 100644
--- a/res/res.go
+++ b/res/res.go
@@ -70,6 +70,9 @@ var RepoTree string
 //go:embed repo/file.html
 var RepoFile string
 
+//go:embed repo/blame.html
+var RepoBlame string
+
 //go:embed repo/refs.html
 var RepoRefs string
 
diff --git a/src/goit/git.go b/src/goit/git.go
index 5464296..101eb8a 100644
--- a/src/goit/git.go
+++ b/src/goit/git.go
@@ -359,3 +359,18 @@ func CommitCount(repo, branch string, hash plumbing.Hash) (uint64, error) {
 
 	return count, nil
 }
+
+func GetFileType(file *object.File) (string, error) {
+	rc, err := file.Blob.Reader()
+	if err != nil {
+		return "", err
+	}
+	defer rc.Close()
+
+	buf := make([]byte, min(file.Size, 512))
+	if _, err := rc.Read(buf); err != nil {
+		return "", err
+	}
+
+	return http.DetectContentType(buf), nil
+}
diff --git a/src/goit/http.go b/src/goit/http.go
index 1fea509..62f81ed 100644
--- a/src/goit/http.go
+++ b/src/goit/http.go
@@ -40,6 +40,7 @@ func init() {
 	template.Must(Tmpl.New("repo/commit").Parse(res.RepoCommit))
 	template.Must(Tmpl.New("repo/tree").Parse(res.RepoTree))
 	template.Must(Tmpl.New("repo/file").Parse(res.RepoFile))
+	template.Must(Tmpl.New("repo/blame").Parse(res.RepoBlame))
 	template.Must(Tmpl.New("repo/refs").Parse(res.RepoRefs))
 }
 
diff --git a/src/main.go b/src/main.go
index 590a187..879c6e5 100644
--- a/src/main.go
+++ b/src/main.go
@@ -313,6 +313,10 @@ found:
 			rctx.URLParams.Add("*", strings.TrimPrefix(spath, "/raw/"))
 			protect(http.HandlerFunc(repo.HandleRaw)).ServeHTTP(w, r)
 
+		case strings.HasPrefix(spath, "/blame/"):
+			rctx.URLParams.Add("*", strings.TrimPrefix(spath, "/blame/"))
+			protect(http.HandlerFunc(repo.HandleBlame)).ServeHTTP(w, r)
+
 		case strings.HasPrefix(spath, "/download"):
 			rctx.URLParams.Add("*", strings.TrimLeft(strings.TrimPrefix(spath, "/download"), "/"))
 			protect(http.HandlerFunc(repo.HandleDownload)).ServeHTTP(w, r)
diff --git a/src/repo/blame.go b/src/repo/blame.go
new file mode 100644
index 0000000..c42d3bb
--- /dev/null
+++ b/src/repo/blame.go
@@ -0,0 +1,178 @@
+// Copyright (C) 2023, Jakob Wakeling
+// All rights reserved.
+
+package repo
+
+import (
+	"errors"
+	"fmt"
+	"html/template"
+	"net/http"
+	"path"
+	"strings"
+
+	"github.com/Jamozed/Goit/src/goit"
+	"github.com/Jamozed/Goit/src/util"
+	"github.com/dustin/go-humanize"
+	"github.com/go-chi/chi/v5"
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/plumbing/object"
+)
+
+func HandleBlame(w http.ResponseWriter, r *http.Request) {
+	auth, user, err := goit.Auth(w, r, true)
+	if err != nil {
+		util.PrintFuncError(err)
+		goit.HttpError(w, http.StatusInternalServerError)
+		return
+	}
+
+	tpath := chi.URLParam(r, "*")
+
+	repo, err := goit.GetRepoByName(chi.URLParam(r, "repo"))
+	if err != nil {
+		goit.HttpError(w, http.StatusInternalServerError)
+		return
+	} else if repo == nil || !goit.IsVisible(repo, auth, user) {
+		goit.HttpError(w, http.StatusNotFound)
+		return
+	}
+
+	type Bline struct {
+		Hash, ShortHash, Author, Date, Line string
+		LineHTML                            template.HTML
+	}
+
+	data := struct {
+		HeaderFields
+		Title, Path, LineC, Size, Mode string
+		Blines                         []Bline
+		PathHTML                       template.HTML
+	}{
+		Title:        repo.Name + " - " + tpath,
+		HeaderFields: GetHeaderFields(auth, user, repo, r.Host),
+	}
+
+	gr, err := git.PlainOpen(goit.RepoPath(repo.Name, true))
+	if err != nil {
+		util.PrintFuncError(err)
+		goit.HttpError(w, http.StatusInternalServerError)
+		return
+	}
+
+	ref, err := gr.Head()
+	if errors.Is(err, plumbing.ErrReferenceNotFound) {
+		goit.HttpError(w, http.StatusNotFound)
+		return
+	} else if err != nil {
+		util.PrintFuncError(err)
+		goit.HttpError(w, http.StatusInternalServerError)
+		return
+	}
+
+	if readme, _ := findPattern(gr, ref, readmePattern); readme != "" {
+		data.Readme = path.Join("/", repo.Name, "file", readme)
+	}
+	if licence, _ := findPattern(gr, ref, licencePattern); licence != "" {
+		data.Licence = path.Join("/", repo.Name, "file", licence)
+	}
+
+	commit, err := gr.CommitObject(ref.Hash())
+	if err != nil {
+		util.PrintFuncError(err)
+		goit.HttpError(w, http.StatusInternalServerError)
+		return
+	}
+
+	file, err := commit.File(tpath)
+	if errors.Is(err, object.ErrFileNotFound) {
+		goit.HttpError(w, http.StatusNotFound)
+		return
+	} else if err != nil {
+		util.PrintFuncError(err)
+		goit.HttpError(w, http.StatusInternalServerError)
+		return
+	}
+
+	data.Mode = util.ModeString(uint32(file.Mode))
+	data.Path = file.Name
+	data.Size = humanize.IBytes(uint64(file.Size))
+
+	parts := strings.Split(file.Name, "/")
+	htmlPath := "<b style=\"padding-left: 0.4rem;\"><a href=\"/" + repo.Name + "/tree\">" + repo.Name + "</a></b>/"
+	dirPath := ""
+
+	for i := 0; i < len(parts)-1; i += 1 {
+		dirPath = path.Join(dirPath, parts[i])
+		htmlPath += "<a href=\"/" + repo.Name + "/tree/" + dirPath + "\">" + parts[i] + "</a>/"
+	}
+	htmlPath += parts[len(parts)-1]
+
+	data.PathHTML = template.HTML(htmlPath)
+
+	ftype, err := goit.GetFileType(file)
+	if err != nil {
+		util.PrintFuncError(err)
+		goit.HttpError(w, http.StatusInternalServerError)
+		return
+	}
+
+	/* Only populate blines for text files */
+	if strings.HasPrefix(ftype, "text") {
+		rc, err := file.Blob.Reader()
+		if err != nil {
+			util.PrintFuncError(err)
+			goit.HttpError(w, http.StatusInternalServerError)
+			return
+		}
+		defer rc.Close()
+
+		buf := make([]byte, min(file.Size, (10*1024*1024)))
+		if _, err := rc.Read(buf); err != nil {
+			util.PrintFuncError(err)
+			goit.HttpError(w, http.StatusInternalServerError)
+			return
+		}
+
+		body, _, err := Highlight(file.Name, string(buf), true)
+		if err != nil {
+			util.PrintFuncError(err)
+			goit.HttpError(w, http.StatusInternalServerError)
+			return
+		}
+
+		blame, err := git.Blame(commit, tpath)
+		if err != nil {
+			util.PrintFuncError(err)
+			goit.HttpError(w, http.StatusInternalServerError)
+			return
+		}
+
+		htmlLines := strings.Split(body, "\n")
+
+		for i, bline := range blame.Lines {
+			data.Blines = append(data.Blines, Bline{
+				Hash:      bline.Hash.String(),
+				ShortHash: bline.Hash.String()[:7],
+				Author:    bline.AuthorName,
+				Date:      bline.Date.Format("2006-01-02 15:04:05"),
+				Line:      bline.Text,
+			})
+
+			if i < len(htmlLines) {
+				htmlLines[i] = strings.TrimPrefix(htmlLines[i], "</span></span>")
+				htmlLines[i] += "</span></span>"
+				data.Blines[i].LineHTML = template.HTML(htmlLines[i])
+			}
+		}
+
+		data.Blines = append(data.Blines, Bline{})
+	}
+
+	data.LineC = fmt.Sprint(len(data.Blines), " lines")
+
+	if err := goit.Tmpl.ExecuteTemplate(w, "repo/blame", data); err != nil {
+		util.PrintFuncError(err)
+	}
+}
diff --git a/src/repo/file.go b/src/repo/file.go
index 01badea..161573e 100644
--- a/src/repo/file.go
+++ b/src/repo/file.go
@@ -7,8 +7,6 @@ import (
 	"errors"
 	"fmt"
 	"html/template"
-	"io"
-	"log"
 	"net/http"
 	"path"
 	"strings"
@@ -25,7 +23,7 @@ import (
 func HandleFile(w http.ResponseWriter, r *http.Request) {
 	auth, user, err := goit.Auth(w, r, true)
 	if err != nil {
-		log.Println("[admin]", err.Error())
+		util.PrintFuncError(err)
 		goit.HttpError(w, http.StatusInternalServerError)
 	}
 
@@ -45,6 +43,7 @@ func HandleFile(w http.ResponseWriter, r *http.Request) {
 		Title, Path, LineC, Size, Mode string
 		Lines                          []string
 		HtmlBody, HtmlPath, BodyCss    template.HTML
+		IsText                         bool
 	}{
 		Title:        repo.Name + " - " + tpath,
 		HeaderFields: GetHeaderFields(auth, user, repo, r.Host),
@@ -52,7 +51,7 @@ func HandleFile(w http.ResponseWriter, r *http.Request) {
 
 	gr, err := git.PlainOpen(goit.RepoPath(repo.Name, true))
 	if err != nil {
-		log.Println("[/repo/file]", err.Error())
+		util.PrintFuncError(err)
 		goit.HttpError(w, http.StatusInternalServerError)
 		return
 	}
@@ -62,7 +61,7 @@ func HandleFile(w http.ResponseWriter, r *http.Request) {
 		goit.HttpError(w, http.StatusNotFound)
 		return
 	} else if err != nil {
-		log.Println("[/repo/file]", err.Error())
+		util.PrintFuncError(err)
 		goit.HttpError(w, http.StatusInternalServerError)
 		return
 	}
@@ -76,7 +75,7 @@ func HandleFile(w http.ResponseWriter, r *http.Request) {
 
 	commit, err := gr.CommitObject(ref.Hash())
 	if err != nil {
-		log.Println("[/repo/file]", err.Error())
+		util.PrintFuncError(err)
 		goit.HttpError(w, http.StatusInternalServerError)
 		return
 	}
@@ -86,7 +85,7 @@ func HandleFile(w http.ResponseWriter, r *http.Request) {
 		goit.HttpError(w, http.StatusNotFound)
 		return
 	} else if err != nil {
-		log.Println("[/repo/file]", err.Error())
+		util.PrintFuncError(err)
 		goit.HttpError(w, http.StatusInternalServerError)
 		return
 	}
@@ -107,46 +106,46 @@ func HandleFile(w http.ResponseWriter, r *http.Request) {
 
 	data.HtmlPath = template.HTML(htmlPath)
 
-	if rc, err := file.Blob.Reader(); err != nil {
-		log.Println("[/repo/file]", err.Error())
+	ftype, err := goit.GetFileType(file)
+	if err != nil {
+		util.PrintFuncError(err)
 		goit.HttpError(w, http.StatusInternalServerError)
 		return
-	} else {
-		buf := make([]byte, min(file.Size, 512))
+	}
+
+	/* Only populate lines for text files */
+	if strings.HasPrefix(ftype, "text") {
+		rc, err := file.Blob.Reader()
+		if err != nil {
+			util.PrintFuncError(err)
+			goit.HttpError(w, http.StatusInternalServerError)
+			return
+		}
+		defer rc.Close()
 
+		buf := make([]byte, min(file.Size, (10*1024*1024)))
 		if _, err := rc.Read(buf); err != nil {
-			log.Println("[/repo/file]", err.Error())
+			util.PrintFuncError(err)
 			goit.HttpError(w, http.StatusInternalServerError)
 			return
 		}
 
-		if strings.HasPrefix(http.DetectContentType(buf), "text") {
-			buf2 := make([]byte, min(file.Size-int64(len(buf)), (10*1024*1024)-int64(len(buf))))
-			if _, err := rc.Read(buf2); err != nil && !errors.Is(err, io.EOF) {
-				log.Println("[/repo/file]", err.Error())
-				goit.HttpError(w, http.StatusInternalServerError)
-				return
-			}
-
-			body := string(append(buf, buf2...))
-			buf, css, err := Highlight(file.Name, body)
-			if err != nil {
-				log.Println("[/repo/file]", err.Error())
-				goit.HttpError(w, http.StatusInternalServerError)
-				return
-			}
-
-			data.HtmlBody = template.HTML(buf)
-			data.BodyCss = template.HTML("<style>" + css + "</style>")
-			data.Lines = strings.Split(body, "\n")
+		body, css, err := Highlight(file.Name, string(buf), false)
+		if err != nil {
+			util.PrintFuncError(err)
+			goit.HttpError(w, http.StatusInternalServerError)
+			return
 		}
 
-		rc.Close()
+		data.HtmlBody = template.HTML(body)
+		data.BodyCss = template.HTML("<style>" + css + "</style>")
+		data.Lines = strings.Split(string(buf), "\n")
+		data.IsText = true
 	}
 
 	data.LineC = fmt.Sprint(len(data.Lines), " lines")
 
 	if err := goit.Tmpl.ExecuteTemplate(w, "repo/file", data); err != nil {
-		log.Println("[/repo/file]", err.Error())
+		util.PrintFuncError(err)
 	}
 }
diff --git a/src/repo/highlight.go b/src/repo/highlight.go
index 16e75ca..2be1ef0 100644
--- a/src/repo/highlight.go
+++ b/src/repo/highlight.go
@@ -12,7 +12,7 @@ import (
 	"github.com/alecthomas/chroma/styles"
 )
 
-func Highlight(name, input string) (string, string, error) {
+func Highlight(name, input string, splittable bool) (string, string, error) {
 	var buf, css bytes.Buffer
 
 	lexer := lexers.Match(name)
@@ -20,7 +20,7 @@ func Highlight(name, input string) (string, string, error) {
 		lexer = lexers.Fallback
 	}
 
-	formatter := html.New(html.WithClasses(true))
+	formatter := html.New(html.WithClasses(!splittable), html.PreventSurroundingPre(splittable))
 
 	iter, err := lexer.Tokenise(nil, input)
 	if err != nil {
diff --git a/src/repo/tree.go b/src/repo/tree.go
index 22e307f..3a14e26 100644
--- a/src/repo/tree.go
+++ b/src/repo/tree.go
@@ -41,7 +41,7 @@ func HandleTree(w http.ResponseWriter, r *http.Request) {
 
 	type row struct {
 		Mode, Name, Path, RawPath, Size string
-		IsFile, B                       bool
+		IsFile, IsText, B               bool
 	}
 	data := struct {
 		HeaderFields
@@ -129,7 +129,7 @@ func HandleTree(w http.ResponseWriter, r *http.Request) {
 
 		for _, v := range tree.Entries {
 			var fpath, rpath, size string
-			var isFile bool
+			var isFile, isText bool
 
 			if v.Mode&0o40000 == 0 {
 				fpath = path.Join("file", tpath, v.Name)
@@ -157,6 +157,16 @@ func HandleTree(w http.ResponseWriter, r *http.Request) {
 
 				size = humanize.IBytes(sz)
 				totalSize += sz
+
+				file, err := tree.File(v.Name)
+				if err == nil {
+					ftype, err := goit.GetFileType(file)
+					if err == nil {
+						if strings.HasPrefix(ftype, "text") {
+							isText = true
+						}
+					}
+				}
 			} else {
 				fpath = path.Join("tree", tpath, v.Name)
 				rpath = path.Join(tpath, v.Name)
@@ -196,7 +206,7 @@ func HandleTree(w http.ResponseWriter, r *http.Request) {
 
 			data.Files = append(data.Files, row{
 				Mode: util.ModeString(uint32(v.Mode)), Name: v.Name, Path: fpath, RawPath: rpath, Size: size,
-				IsFile: isFile, B: util.If(strings.HasSuffix(size, " B"), true, false),
+				IsFile: isFile, IsText: isText, B: util.If(strings.HasSuffix(size, " B"), true, false),
 			})
 		}
 
diff --git a/src/util/log.go b/src/util/log.go
index d22fcb6..fa149fe 100644
--- a/src/util/log.go
+++ b/src/util/log.go
@@ -3,10 +3,24 @@
 
 package util
 
-import "log"
+import (
+	"log"
+	"runtime"
+)
 
 var Debug = false
 
+func PrintFuncError(err error) {
+	pc, _, _, ok := runtime.Caller(1)
+	if !ok {
+		log.Println(err)
+		return
+	}
+
+	fn := runtime.FuncForPC(pc)
+	log.Printf("[%s] %s\n", fn.Name(), err.Error())
+}
+
 func Debugln(v ...any) {
 	if Debug {
 		var a = []any{"\033[34m[DEBUG]\033[0m"}