commit - 360144565ad29c3d80c0e6bf422cfcf549ad02be
commit + c85b1bffb399ae7b4294cf58d1fe1f1d2132b6fb
blob - 81eac79a9cd3dfc9be76ca97a8b4be81496bce9a (mode 755)
blob + /dev/null
--- got_last_commit_file.py
+++ /dev/null
-#!/usr/local/bin/python3
-"""
-CGI script to redirect to the latest version of a file in gotwebd
-URL pattern: /file/?path=<repo>&file=<filepath>
-"""
-
-
-#Copyright 2026 vincent.delft@gmail.com
-#
-#Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
-#
-#1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
-#
-#2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
-#
-#3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
-#
-#THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-import os
-import sys
-import subprocess
-import os.path
-import time
-from urllib.parse import parse_qs, unquote, urlencode
-
-
-REPO_BASE = "/got/public" # full path is /var/www/got/public
-DEBUG = 0 # from 0 to 9
-LOG_FID = open("/logs/cgi.log","a") # this will go into /var/www/logs/cgi.log
-
-def log(level, text):
- if level <= DEBUG:
- LOG_FID.write(f"{text}\n")
-
-def get_query_params():
- """Parse query string parameters"""
- query_string = os.environ.get('QUERY_STRING', '')
- params = parse_qs(query_string)
-
- # Get first value for each param (parse_qs returns lists)
- repo = params.get('path', [''])[0]
- file_path = params.get('file', [''])[0]
-
- #return unquote(repo), unquote(file_path)
- return repo, file_path
-
-def get_latest_commit(repo_path):
- """Get the latest commit hash from a got repository"""
- try:
- # Use got log to get latest commit
- result = subprocess.run(
- ['got', 'log', '-r', repo_path, '-l', '1'],
- capture_output=True,
- text=True,
- timeout=5
- )
-
- if result.returncode == 0:
- # Parse first line: "commit <hash>"
- for line in result.stdout.split('\n'):
- if line.startswith('commit '):
- return line.split()[1]
- print("return code not null:", result.returncode)
- return None
- except Exception:
- return None
-
-def send_response(status, content_type, body, location=None):
- """Send HTTP response"""
- print(f"Status: {status}")
- if location:
- print(f"Location: {location}")
- print(f"Content-Type: {content_type}")
- print() # Empty line between headers and body
- print(body)
-
-def main():
- env = os.environ
- log(0,f"{time.ctime()} - {env.get('REMOTE_ADDR','NA')} - {env.get('HTTP_USER_AGENT','NA')} - {env.get('REQUEST_METHOD','NA')} {env.get('SERVER_NAME','NA')}/{env.get('QUERY_STRING','NA')}")
- # Parse parameters
- repo, file_path = get_query_params()
- log(1,'repo, file_path:%s %s' % (repo, file_path))
-
- # Validate inputs
- if not repo or not file_path:
- log(0, '%s - reply 400 - ERROR:missing path and/or file parameters' % (time.ctime()))
- send_response(
- "400 Bad Request",
- "text/plain",
- "Error: Missing required parameters 'path' and/or 'file'"
- )
- return
-
- # Build repository path
- repo_path = os.path.join(REPO_BASE, repo)
- log(1, 'repo_path:%s %s' % (repo_path, os.path.isdir(repo_path)))
-
- # Check if repository exists
- if not os.path.isdir(repo_path):
- log(0, '%s - reply 404 - ERROR:no repository found at %s' % (time.ctime(), repo_path))
- send_response(
- "404 Not Found",
- "text/plain",
- f"Error: Repository '{repo}' not found"
- )
- return
-
- # Get latest commit
- commit = get_latest_commit(repo_path)
- log(1, 'commit:%s' % (commit))
-
- if not commit:
- log(0, '%s - reply 500 - ERROR:no "commit" found in got log for %s' % (time.ctime(), repo_path))
- send_response(
- "500 Internal Server Error",
- "text/plain",
- f"Error: Could not retrieve latest commit for repository '{repo}'"
- )
- return
-
- # Build gotwebd URL
- folder, filename = os.path.split(file_path)
- if folder in ("/"):
- folder = ""
- params = {
- "action": "blobraw",
- "commit": commit,
- "folder": folder,
- "file": filename,
- "path": repo
- }
-
- log(1, 'params:%s' % (params))
- gotwebd_url = "/?" + urlencode(params)
- log(0, '%s - reply 302 - %s' % (time.ctime(), gotwebd_url))
- #/?action=blobraw&commit={commit}&folder=&file={file_path}&path={repo}"
-
- # Send redirect
- send_response(
- "302 Found",
- "text/plain",
- "Redirecting to latest version...",
- location=gotwebd_url
- )
- LOG_FID.close()
-
-if __name__ == "__main__":
- main()
blob - /dev/null
blob + 26c1f09c6ab6e6c916d3f92e6820f16bdb717ac6 (mode 644)
--- /dev/null
+++ last_commit_cgi/REAMDE.md
+## Always Display the Latest Version of a File from a Got Repository
+
+When browsing a repository through a web interface, it is common to want a stable link that always shows the most recent version of a specific file. Unfortunately, the default web interface for Game of Trees repositories links files to a precise commit. As a result, each update to the file produces a new URL, which is inconvenient for documentation, external references, or automated integrations.
+
+For example, a standard link generated by the web interface points to a fixed commit hash, meaning it will never change even if the file is updated later.
+
+To solve this limitation, we can introduce a small CGI gateway that dynamically resolves the latest commit containing the requested file and then redirects the browser to the correct URL. This article explains how to deploy such a mechanism on an OpenBSD server where the repository daemon and web interface are already installed.
+
+
+## Principle of the Solution
+
+Instead of linking directly to a commit-specific URL, users will access a stable endpoint such as:
+
+ https://<servername>/file/?file=README.md&path=myrepo.git
+
+The CGI script receives:
+
+* the file path inside the repository (`file`)
+* the repository name (`path`)
+
+It then determines the latest commit that modified the file and issues an HTTP redirect to the proper web interface URL. The browser ultimately lands on the correct page, while the public link remains constant over time.
+
+
+## Prerequisites
+
+Before installing the script, ensure that Python 3 is available inside the web server chroot environment. On OpenBSD, CGI programs run in a restricted filesystem, so Python must be copied there explicitly. You can check [my other blog post which explain how to proceed](/post/post_20260217)
+
+
+## Installation of the CGI Script
+
+Get the script via [my own got repository](https://repo.vincentdelft.be/?action=summary&path=mygot.git) by doing this:
+
+ got clone ssh://anon@repo.vincentdelft.be/mygot
+ got checkout mygot.git
+ or
+ git clone ssh://anon@repo.vincentdelft.be/mygot mygot
+ cd mygot
+
+Please edit the script `got_last_commit_file.py` and adapt the BASE_REPO to your specific context.
+
+You could also see the `DEBUG` variable wich will help you to understand problematic situations.
+
+Copy the script `got_last_commit_file.py` into the CGI directory and set the proper ownership and permissions so that the web server can execute it:
+
+ cp got_last_commit_file.py /var/www/cgi-bin
+ chown www:www /var/www/cgi-bin/got_last_commit_file.py
+ chmod 755 /var/www/cgi-bin/got_last_commit_file.py
+
+
+## Web Server Configuration (nginx)
+
+In my specific case, I'm using nginx as frontend web server. [httpd](https://man.openbsd.org/httpd) could also surely do the job.
+
+In my nginx.onf file I add a dedicated location block for the `/file/` endpoint. The complete server configuration below includes both the gotwebd repository interface and the CGI redirect handler for clarity.
+
+ server {
+ listen 443 ssl;
+ server_name <server name>;
+ access_log /var/log/nginx/<server name>.access.log;
+ error_log /var/log/nginx/error.log;
+
+ ssl_certificate <path/to/certificates.pem>;
+ ssl_certificate_key <path/to/certificate.key>;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ # Main FastCGI handler for the repository interface
+ location / {
+ include fastcgi_params;
+ fastcgi_pass unix:/run/gotweb.sock;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+ fastcgi_param GATEWAY_INTERFACE CGI/1.1;
+ fastcgi_read_timeout 300;
+ }
+
+ # Static assets
+ location ~ ^/(.*\.(css|png|svg|ico|jpg|gif|js))$ {
+ alias /htdocs/gotwebd/$1;
+ expires 30d;
+ access_log off;
+ }
+
+ # CGI endpoint returning the latest version of a file
+ location /file/ {
+ fastcgi_pass unix:/run/slowcgi.sock;
+ fastcgi_param SCRIPT_FILENAME /cgi-bin/got_last_commit_file.py;
+ include fastcgi_params;
+ }
+ }
+
+
+## Permissions and Access Control
+
+It's important to undestand that slowcgi runs strictly as `www:www`.
+
+Because of that we will a perform some small modifications in our Group so _gotd keep and read/write access tot he repository, but other systmes gotwebd and slowcgi will only have read access. Other user have no access at all.
+
+Add _gotwebd to the `www` group:
+
+ usermod -G www _gotwebd
+
+Adjust repository permissions so they remain writable by the daemon but readable by the web layer:
+
+ cd /var/www
+ chgrp -R www path/to/repos
+ chmod -R 750 path/to/repos
+
+At the end the whole repository will be owned by the user _gotd with "rwx" permissions and group "www" with permission "r-x". Other will have nothing "---". This the representation of the permission "750"
+
+
+## Enabling the CGI Service
+
+Activate the CGI wrapper and start it:
+
+ rcctl enable slowcgi
+ rcctl start slowcgi
+
+## Validating the nginx Configuration
+
+Before reloading the server, verify the configuration:
+
+ nginx -t
+
+A successful validation should produce output similar to:
+
+ nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
+ nginx: configuration file /etc/nginx/nginx.conf test is successful
+
+
+## Testing the Setup
+
+We are now ready for some tests.
+You can now request any file from a published repository using the stable endpoint:
+
+ https://<servername>/file/?file=testfile.txt&path=myrepo.git
+
+Where:
+
+* `path` is the repository name exposed by the web interface
+* `file` is the path to the file inside that repository
+
+Example:
+
+ https://repo.vincentdelft.be/file/?file=README.md&path=zsh.git
+
+If everything is configured correctly, the request will automatically redirect to the page displaying the latest committed version of the file.
+
+If you do it via [curl](xx), you will have such result:
+
+ curl "https://repo.vincentdelft.be/file/?file=README.md&path=mypekwm.git"
+ Redirecting to latest version...
+
+Which is a success ;)
+
+## Logging
+
+This cgi script write logging data into the file: /logs/cgi.log (in the chroot environment for sure).
+If the `DEBUG` variable is bigger than 0, you will see more details.
+If `DEBUG` is set to 0, you will see in the log the request and the response
+
+
+## Conclusion
+
+This lightweight CGI approach provides a simple yet powerful improvement to repository browsing. It enables permanent links to files that always reflect the newest content without modifying the repository web interface itself.
+
+Such stable URLs are especially useful for documentation, configuration management, automation tools, or any scenario where referencing “the current version” of a file is required.
+
+With minimal configuration and no invasive changes, you gain a clean, reliable mechanism for publishing live repository content.
+
blob - /dev/null
blob + dfb80a4cd9bb8e394023ae4a0083e6deee8d5a59 (mode 755)
--- /dev/null
+++ last_commit_cgi/got_last_commit_file.py
+#!/usr/local/bin/python3
+"""
+CGI script to redirect to the latest version of a file in gotwebd
+URL pattern: /file/?path=<repo>&file=<filepath>
+"""
+
+
+#Copyright 2026 vincent.delft@gmail.com
+#
+#Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+#
+#1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+#
+#2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+#
+#3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+#
+#THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import os
+import sys
+import subprocess
+import os.path
+import time
+from urllib.parse import parse_qs, unquote, urlencode
+
+
+# This cgi is planned to be triggered by slowcgi on an OpenBSD server.
+# this means that this script will be executed in chroot environment. Normally /var/www
+
+REPO_BASE = "/got/public" # the real full path is /var/www/got/public
+DEBUG = 0 # from 0 to 9
+LOG_FID = open("/logs/cgi.log","a") # the real full path is /var/www/logs/cgi.log
+
+def log(level, text):
+ if level <= DEBUG:
+ LOG_FID.write(f"{text}\n")
+
+def get_query_params():
+ """Parse query string parameters"""
+ query_string = os.environ.get('QUERY_STRING', '')
+ params = parse_qs(query_string)
+
+ # Get first value for each param (parse_qs returns lists)
+ repo = params.get('path', [''])[0]
+ file_path = params.get('file', [''])[0]
+
+ #return unquote(repo), unquote(file_path)
+ return repo, file_path
+
+def get_latest_commit(repo_path):
+ """Get the latest commit hash from a got repository"""
+ try:
+ # Use got log to get latest commit
+ result = subprocess.run(
+ ['got', 'log', '-r', repo_path, '-l', '1'],
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+
+ if result.returncode == 0:
+ # Parse first line: "commit <hash>"
+ for line in result.stdout.split('\n'):
+ if line.startswith('commit '):
+ return line.split()[1]
+ print("return code not null:", result.returncode)
+ return None
+ except Exception:
+ return None
+
+def send_response(status, content_type, body, location=None):
+ """Send HTTP response"""
+ print(f"Status: {status}")
+ if location:
+ print(f"Location: {location}")
+ print(f"Content-Type: {content_type}")
+ print() # Empty line between headers and body
+ print(body)
+
+def main():
+ env = os.environ
+ log(0,f"{time.ctime()} - {env.get('REMOTE_ADDR','NA')} - {env.get('HTTP_USER_AGENT','NA')} - {env.get('REQUEST_METHOD','NA')} {env.get('SERVER_NAME','NA')}/{env.get('QUERY_STRING','NA')}")
+ # Parse parameters
+ repo, file_path = get_query_params()
+ log(1,'repo, file_path:%s %s' % (repo, file_path))
+
+ # Validate inputs
+ if not repo or not file_path:
+ log(0, '%s - reply 400 - ERROR:missing path and/or file parameters' % (time.ctime()))
+ send_response(
+ "400 Bad Request",
+ "text/plain",
+ "Error: Missing required parameters 'path' and/or 'file'"
+ )
+ return
+
+ # Build repository path
+ repo_path = os.path.join(REPO_BASE, repo)
+ log(1, 'repo_path:%s %s' % (repo_path, os.path.isdir(repo_path)))
+
+ # Check if repository exists
+ if not os.path.isdir(repo_path):
+ log(0, '%s - reply 404 - ERROR:no repository found at %s' % (time.ctime(), repo_path))
+ send_response(
+ "404 Not Found",
+ "text/plain",
+ f"Error: Repository '{repo}' not found"
+ )
+ return
+
+ # Get latest commit
+ commit = get_latest_commit(repo_path)
+ log(1, 'commit:%s' % (commit))
+
+ if not commit:
+ log(0, '%s - reply 500 - ERROR:no "commit" found in got log for %s' % (time.ctime(), repo_path))
+ send_response(
+ "500 Internal Server Error",
+ "text/plain",
+ f"Error: Could not retrieve latest commit for repository '{repo}'"
+ )
+ return
+
+ # Build gotwebd URL
+ folder, filename = os.path.split(file_path)
+ if folder in ("/"):
+ folder = ""
+ params = {
+ "action": "blobraw",
+ "commit": commit,
+ "folder": folder,
+ "file": filename,
+ "path": repo
+ }
+
+ log(1, 'params:%s' % (params))
+ gotwebd_url = "/?" + urlencode(params)
+ log(0, '%s - reply 302 - %s' % (time.ctime(), gotwebd_url))
+ #/?action=blobraw&commit={commit}&folder=&file={file_path}&path={repo}"
+
+ # Send redirect
+ send_response(
+ "302 Found",
+ "text/plain",
+ "Redirecting to latest version...",
+ location=gotwebd_url
+ )
+ LOG_FID.close()
+
+if __name__ == "__main__":
+ main()