From: Lady Date: Sat, 27 Aug 2022 22:55:00 +0000 (-0700) Subject: Initial commit X-Git-Tag: 0.1.0^0 X-Git-Url: https://git.ladys.computer/x_status_git/commitdiff_plain/2659b92ca7ae70783227a5de0c000e34b984e2c2?ds=sidebyside Initial commit --- 2659b92ca7ae70783227a5de0c000e34b984e2c2 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6e6d778 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +USERNAME := USERNAME +DOMAIN := DOMAIN +GIT_REPOSITORY := /home/$(USERNAME)/Status.git + +nothing: + @echo 'Type `make remote` to update the remote post-receive script.' + +remote: + scp post-receive $(USERNAME)@$(DOMAIN):$(GIT_REPOSITORY)/hooks/post-receive + +.PHONY: nothing remote ; diff --git a/README.markdown b/README.markdown new file mode 100644 index 0000000..2c71b22 --- /dev/null +++ b/README.markdown @@ -0,0 +1,281 @@ +# Status.git + +A minimal git‐based self‐hosted status publishing solution. + +## What It Is + + + A Python script, `post-receive`, suitable for use as a post‐receive + Git hook, which generates a number of `.jsonld` files from a + source repository of text posts. + + + A small collection of H·T·M·L files designed to display the + generated `.jsonld` in a human‐readable fashion. + +## What It Is Not + + + A server or server configuration (you will need to supply these + yourself; see below). + + + Suitable for deployment on a simple filesystem server like + NeoCities or GitHub Pages (it requires both Git hooks and H·T·T·P + routing). + + + Interactive in any way (i·e “social media”). + + + Compatible with the ActivityPub fediverse (or any other push‐based + social media platform). + +## Script Configuration + +You will need to edit the `post-receive` script to adjust the following + constants to match your setup :— + + + **`GIT_DIRECTORY`**: + The absolute path to this Git repository on your server. + It is expected that this will be a bare repository (ending in + `.git`). + + + **`BUILD_DIRECTORY`**: + In order to access the files when you push, the repository will be + checked out here. + **This directory will be deleted on every push.** + + + **`PUBLIC_DIRECTORY`**: + The directory that your server serves files from. + **This directory will be deleted on every push,** so if you need to + serve additional files (i·e those not generated by this script), + you should place those files in a different directory and adjust + your server configuration accordingly. + Note that the `post-receive` script and associted H·T·M·L files + provided by this repository expect a certain server configuration + described below. + + + **`PUBLIC_URL`**: + The U·R·L that you are serving your site from (with no trailing + slash). + You cannot serve Status.git from a subdirectory. + +A `Makefile` is provided to make installing the `post-receive` script + on your server easy (assuming you have `ssh` access). +You’ll need to supply some variables there, too :— + + + **`USERNAME`**: + Your SSH username. + + + **`DOMAIN`**: + The domain or other address of your site. + + + **`GIT_REPOSITORY`**: + The absolute path to this Git repository, as above. + +## Server Configuration + +Your server should be configured to serve the following files from the + provided `PUBLIC_DIRECTORY` in response to the following requests :— + +### H·T·M·L responses + +These responses **must** be served with a `Content-Type` of + `text/html;charset=UTF-8` (or equivalent). +Note that these paths **do not** have a trailing slash. + + + **`GET /`**: + Serve the file at `/index.html`. + + + **`GET /statuses`**: + Serve the file at `/.statuses.html`. + A `Link` header with the value + `;rel=meta;type="application/ld+json"` (or + equivalent) **must** be provided. + + + **`GET /$YYYY-MM`** (where `$YYYY-MM` is an `xsd:gYearMonth`): + Serve the file at `/.topic.html`. + A `Link` header with the value + `;rel=meta;type="application/ld+json"` (or + equivalent) **must** be provided. + + + **`GET /$YYYY-MM/*`** (where `$YYYY-MM` is an `xsd:gYearMonth`): + Serve the file at `/.status.html`. + A `Link` header with the value + `;rel=meta;type="application/ld+json"` (or + equivalent) **must** be provided. + + + **`GET /topics`**: + Serve the file at `/.topics.html`. + A `Link` header with the value + `;rel=meta;type="application/ld+json"` (or + equivalent) **must** be provided. + + + **`GET /topics/$TOPIC`** (where `$TOPIC` matches `[0-9A-Za-z_-]+`): + Serve the file at `/.topic.html`. + A `Link` header with the value + `;rel=meta;type="application/ld+json"` (or + equivalent) **must** be provided. + + + **`GET /topics/$TOPIC/*`** (where `$TOPIC` matches + `[0-9A-Za-z_-]+`): + Serve the file at `/.status.html`. + A `Link` header with the value + `;rel=meta;type="application/ld+json"` (or + equivalent) **must** be provided. + +### Json‐L·D responses + +These responses **should** be served with a `Content-Type` of + `application/ld+json`. +In all cases, for `/$PATH.jsonld`, this just serves the file at + `/$PATH/index.jsonld`. + + + **`GET /statuses.jsonld`**: + Serve the file at `/statuses/index.jsonld`. + + + **`GET /$YYYY-MM.jsonld`** (where `$YYYY-MM` is an + `xsd:gYearMonth`): + Serve the file at `/$YYYY-MM/index.jsonld`. + + + **`GET /topics.jsonld`**: + Serve the file at `/topics/index.jsonld`. + + + **`GET /topics/$TOPIC.jsonld`** (where `$TOPIC` matches + `[0-9A-Za-z_-]+`): + Serve the file at `/topics/$TOPIC/index.jsonld`. + +### Other Headers + +All responses **should** have a `Access-Control-Allow-Origin` header + with a value of `*` (assuming your server does not use credentials + and is not being served behind a firewall). + +## Committing Statuses To Git + +The `post-receive` script will run whenever you make a commit to the + `live` branch, which should be set as your default. +Statuses are represented by a small collection of files committed to + particular locations in this repository :— + + + Files committed to `/YYYY/MM/DD/HH.MM.SSZ/` (the final component + can actually take any form but a time is **recommended**) are + ordinary statuses. + + + Files committed to `/topic/TOPICNAME/a/b/c/d/xxxx` (where `a`, `b`, + `c`, and `d` are lowercase hexadecimal digits and `xxxx` can be + anything) is a status posted to a specific topic (`TOPICNAME`). + It is **recommended** that you make `a/b/c/d` the first four digits + of an MD5 hash of the status content and `xxxx` the remaining + digits (security is not an issue here, so MD5 is fine). + `TOPICNAME` should have the form `[0-9A-Za-z_-]+`. + +The intent is that “ordinary statuses” are a bit more ephemeral whereas + “topic statuses” can serve as reference material. +Using an MD5 hash for topic statuses ensures you don’t post the same + thing twice. + +The files which represent a status are as follows :— + + + **`text`**: + The text of the status. + Blank lines separate paragraphs; linebreaks will be preserved. + There is a special markup for links: ``, or + `="link text"` if you want to supply link + text. + At present, no other markup is supported. + + + **`0=x_status_git_1.0`**: + This file is **optional** and not currently used for anything, but + indicates that the post follows the `1.0` format. + The contents of this file, if present, **must** be + `x_status_git_1.0`, optionally followed by a trailing newline. + + + **`1=NAME`** (where `NAME` might be anything): + This file is **recommended** and indicates the author of the + status. + Only one author is currently supported. + The value of `NAME` **must** give the name of the author of the + status. + The contents of this file **must** give the author’s URL, + optionally followed by a trailing newline. + + + **`3=YYYY-MM-DD`** (where `YYYY-MM-DD` is a date): + This file is **required** and indicates the date of the status. + Only one date is currently supported. + The value of `YYYY-MM-DD` **should** give the date of the status, + although this is not used (in favour of the full timestamp). + The contents of this file **must** give the full `xsd:dateTime` + timestamp of the status, optionally followed by a trailing + newline. + + + **`4=IDENTIFIER`** (where `IDENTIFIER` might be anything): + This file is **required** and provides an identifier for the + status. + Only one identifier is currently supported. + The contents of this file **must** be an I·R·I which uniquely + identifies the status (for example, a U·U·I·D or `tag:` URI). + The value of `IDENTIFIER` **must** be a locally‐unique identifier + for the status and **should** resemble the contents where + possible. + (Note, however, that `IDENTIFIER` cannot contain slashes and need + not be a valid I·R·I.) + +Files with names that begin with the strings `2=` or `x_status_git_` + are reserved for backwards‐compatible extensions. + +Status.git has no opinion on how these files make their way into the + Git repository, except that all the files for a single status should + be added in the same commit. +The intention is that the simple nature of these files will make them + easy to automate. + +## I Am Computer, How Do I Get Status? + +Assume you are given a U·R·L to a status collection, like `/statuses`, + `/YYYY-MM`, or `/topics/my_topic`. +Make a `HEAD` request to this U·R·L. +If there is a `Link` header with a `rel` of `meta` and a `type` of + `application/ld+json`, make a `GET` request to that U·R·L instead. +Otherwise, make a `GET` request to the U·R·L you were given. + +Assuming the U·R·L you were given was valid, you will receive a + Json‐L·D response with a `@type` which is either an + `OrderedCollection` or an `OrderedCollectionPage`. +One of the following will be true :— + + + The response is an `OrderedCollectionPage`. + Its `items` will be an array of statuses, and the `prev` and + `next` properties will give U·R·Ls for previous and next + pages of statuses (if any exist). + + + The response is an `OrderedCollection` with `first` and + `current` properties. + These properies give the U·R·Ls for the first and latest pages + of statuses, which you can fetch and process as above. + + + Otherwise, the `items` property will be an array of every + status in the collection. + + > If you receive a `Collection` instead of an `OrderedCollection`, + > you are probably looking at a topics listing. + > You will need to choose a topic from the `items` and then fetch it + > to receive the list of statuses. + +Statuses themselves have the following properties :— + + + **`@id`**: + The identifier of the status. + + + **`created`** [`dcterms:created`]: + The creation date for the status, as an `xsd:dateTime`. + + + **`creator`** [`dcterms:creator`] (optional): + The author of the status, as an object with an `@id` and `name` + [`foaf:name`]. + + + **`identifier`** [`dcterms:identifier`]: + An I·R·I which uniquely identifies the status. + This differs from the `@id` in that it is not expected to be + dereferenceable. + + + **`subject`** [`dcterms:subject`] (optional): + The topic of the status, for topic statuses. + + + **`content`** [`sioc:content`]: + The content of the status, as an `rdf:XMLLiteral`. diff --git a/index.html b/index.html new file mode 100644 index 0000000..b315183 --- /dev/null +++ b/index.html @@ -0,0 +1,68 @@ + + +Index + +

Index

+
+
+
+

Topics

+
+ diff --git a/post-receive b/post-receive new file mode 100755 index 0000000..1b20aa8 --- /dev/null +++ b/post-receive @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +from glob import iglob +from itertools import starmap +import json +from os import mkdir +from os.path import exists +from pathlib import Path +import re +from shutil import copy2, rmtree +from subprocess import run +from sys import stdin +from warnings import warn +from xml.dom import XHTML_NAMESPACE +from xml.dom.minidom import getDOMImplementation + +GIT_DIRECTORY = "/home/USERNAME/Status.git" +BUILD_DIRECTORY = "/home/USERNAME/status.site.example/.build" +PUBLIC_DIRECTORY = "/home/USERNAME/status.site.example/public" +PUBLIC_URL = "https://status.site.example" +LANG = "en" +LIVE_BRANCH = "live" + +if stdin.read().split()[-1] == f"refs/heads/{LIVE_BRANCH}": + + print(f"This is an update to the '{LIVE_BRANCH}' branch; regenerating site…") + + # Set up the build directory. + if exists(BUILD_DIRECTORY): + rmtree(BUILD_DIRECTORY) + run(["git", "clone", "--local", "--branch", "live", GIT_DIRECTORY, BUILD_DIRECTORY], capture_output=True, encoding="utf-8") + + # Set up various containers. + months = {} + topics = {} + + # Create an XML representation of the provided status text. + def statusxml (text, version="1.0"): + doc = getDOMImplementation().createDocument(None, "article", None) + articleElt = doc.documentElement + articleElt.setAttribute("xmlns", XHTML_NAMESPACE) + articleElt.setAttribute("lang", LANG) + for para in text.split("\n\n"): + paraElt = articleElt.appendChild(doc.createElement("p")) + for component in re.findall(r'<[a-z]+:[^\s]*>(?:="[^\n"]+")?|\n|[^<\n]+|<(?![a-z]+:[^\s]*>)', para): + if component == "\n": + paraElt.appendChild(doc.createElement("br")) + elif re.fullmatch(r'<[a-z]+:[^\s]*>(?:="[^\n"]+")?', component): + href = component.split(">", maxsplit=1)[0][1:] + anchorElt = paraElt.appendChild(doc.createElement("a")) + anchorElt.setAttribute("href", href) + anchorElt.setAttribute("rel", "noreferrer") + anchorElt.appendChild(doc.createTextNode(component if len(href) == len(component) - 2 else component[len(href)+4:-1])) + else: + paraElt.appendChild(doc.createTextNode(component)) + return articleElt.toxml() + + # Map status paths to status objects, or None if there is an error. + # + # The provided path must be to a `text` object. + def statusmap (topic, path): + status = { } + version_path = next(path.parent.glob("0=*")) + if version_path and version_path.name != "0=x_status_git_1.0": + warn(f"Unrecognized version for {path}; skipping.") + return None + if topic: + status["subject"] = topic + author_path = next(path.parent.glob("1=*")) + if author_path: + status["author"] = { "name": author_path.name[2:] } + with author_path.open("r", encoding="utf-8") as text: + status["author"]["@id"] = text.read().strip() + date_path = next(path.parent.glob("3=*")) + datetime = "" + if date_path: + with date_path.open("r", encoding="utf-8") as text: + datetime = text.read().strip() + status["created"] = datetime + else: + warn(f"Missing date for {path}; skipping.") + return None + identifier_path = next(path.parent.glob("4=*")) + identifier = "" + if identifier_path: + identifier = identifier_path.name[2:] + status["@id"] = f"{PUBLIC_URL}/topics/{topic}/{identifier}" if topic else f"{PUBLIC_URL}/{datetime[0:7]}/{identifier}" + with identifier_path.open("r", encoding="utf-8") as text: + status["identifier"] = text.read().strip() + else: + warn(f"Missing identifier for {path}; skipping.") + return None + with path.open("r", encoding="utf-8") as text: + status["content"] = statusxml(text.read().strip()) + return (datetime, identifier, status) + + # Get status paths. + status_paths = [] + for yearpath in Path(f"{BUILD_DIRECTORY}/").glob("[0-9][0-9][0-9][0-9]"): + for monthpath in yearpath.glob("[0-9][0-9]"): + for daypath in monthpath.glob("[0-9][0-9]"): + for statuspath in daypath.glob("*/text"): + status_paths.append((None, statuspath)) + for topicpath in Path(f"{BUILD_DIRECTORY}/").glob("topic/*"): + for hash0path in topicpath.glob("[0-9a-f]"): + for hash1path in hash0path.glob("[0-9a-f]"): + for hash2path in hash1path.glob("[0-9a-f]"): + for hash3path in hash2path.glob("[0-9a-f]"): + for statuspath in hash3path.glob("*/text"): + status_paths.append((topicpath.name, statuspath)) + + # Build status objects and listings. + for (datetime, identifier, status) in sorted(filter(None, starmap(statusmap, status_paths))): + if "subject" in status: + topic = status["subject"] + if topic not in topics: + topics[topic] = { "@context": { "@language": LANG, "activity": "https://www.w3.org/ns/activitystreams#", "dct": "http://purl.org/dc/terms/", "foaf": "http://xmlns.com/foaf/0.1/", "sioc": "http://rdfs.org/sioc/ns#", "OrderedCollection": "activity:OrderedCollection", "items": { "@id": "activity:items", "@type": "@id", "@container": "@list" }, "created": { "@id": "dct:created", "@type": "http://www.w3.org/2001/XMLSchema#dateTime" }, "creator": { "@id": "dct:creator", "@type": "@id" }, "identifier": { "@id": "dct:identifier", "@type": "http://www.w3.org/2001/XMLSchema#anyURI" }, "subject": "dct:subject", "name": "foaf:name", "content": { "@id": "sioc:content", "@type": "http://www.w3.org/1999/02/22-rdf-syntax-ns#XMLLiteral" } }, "@id": f"{PUBLIC_URL}/topics/{topic}", "@type": "OrderedCollection", "items": [], "subject": topic } + topics[topic]["items"].append(status) + else: + yyyy_mm = datetime[0:7] + if yyyy_mm not in months: + months[yyyy_mm] = { "@context": { "@language": LANG, "activity": "https://www.w3.org/ns/activitystreams#", "dct": "http://purl.org/dc/terms/", "foaf": "http://xmlns.com/foaf/0.1/", "sioc": "http://rdfs.org/sioc/ns#", "OrderedCollectionPage": "activity:OrderedCollectionPage", "current": { "@id": "activity:current", "@type": "@id" }, "first": { "@id": "activity:first", "@type": "@id" }, "items": { "@id": "activity:items", "@type": "@id", "@container": "@list" }, "partOf": { "@id": "activity:partOf", "@type": "@id" }, "prev": { "@id": "activity:prev", "@type": "@id" }, "next": { "@id": "activity:next", "@type": "@id" }, "created": { "@id": "dct:created", "@type": "http://www.w3.org/2001/XMLSchema#dateTime" }, "creator": { "@id": "dct:creator", "@type": "@id" }, "identifier": { "@id": "dct:identifier", "@type": "http://www.w3.org/2001/XMLSchema#anyURI" }, "name": "foaf:name", "content": { "@id": "sioc:content", "@type": "http://www.w3.org/1999/02/22-rdf-syntax-ns#XMLLiteral" } }, "@id": f"{PUBLIC_URL}/{yyyy_mm}", "@type": "OrderedCollectionPage", "items": [], "partOf": f"{PUBLIC_URL}/statuses" } + months[yyyy_mm]["items"].append(status) + + # Set up the public directory. + if exists(PUBLIC_DIRECTORY): + rmtree(PUBLIC_DIRECTORY) + mkdir(PUBLIC_DIRECTORY) + + # Copy H·T·M·L files to their expected locations. + copy2(f"{BUILD_DIRECTORY}/index.html", f"{PUBLIC_DIRECTORY}/index.html") + copy2(f"{BUILD_DIRECTORY}/status.html", f"{PUBLIC_DIRECTORY}/.status.html") + copy2(f"{BUILD_DIRECTORY}/statuses.html", f"{PUBLIC_DIRECTORY}/.statuses.html") + copy2(f"{BUILD_DIRECTORY}/topic.html", f"{PUBLIC_DIRECTORY}/.topic.html") + copy2(f"{BUILD_DIRECTORY}/topics.html", f"{PUBLIC_DIRECTORY}/.topics.html") + + # Output month‐based listings and the non‐topic index + statuspairs = list(enumerate(months.items())) + for (index, (yyyy_mm, ld)) in statuspairs: + if not exists(f"{PUBLIC_DIRECTORY}/{yyyy_mm}"): + mkdir(f"{PUBLIC_DIRECTORY}/{yyyy_mm}") + ld["first"] = f"{PUBLIC_URL}/{statuspairs[0][1][0]}" + ld["current"] = f"{PUBLIC_URL}/{statuspairs[-1][1][0]}" + if index > 0: + ld["prev"] = f"{PUBLIC_URL}/{statuspairs[index - 1][1][0]}" + if index < len(statuspairs) - 1: + ld["next"] = f"{PUBLIC_URL}/{statuspairs[index + 1][1][0]}" + with open(f"{PUBLIC_DIRECTORY}/{yyyy_mm}/index.jsonld", "w", encoding="utf-8") as f: + json.dump(ld, f, ensure_ascii=False, allow_nan=False) + if not exists(f"{PUBLIC_DIRECTORY}/statuses"): + mkdir(f"{PUBLIC_DIRECTORY}/statuses") + with open(f"{PUBLIC_DIRECTORY}/statuses/index.jsonld", "w", encoding="utf-8") as f: + json.dump({ "@context": { "@language": LANG, "activity": "https://www.w3.org/ns/activitystreams#", "OrderedCollection": "activity:OrderedCollection", "current": { "@id": "activity:current", "@type": "@id" }, "first": { "@id": "activity:first", "@type": "@id" } }, "@id": f"{PUBLIC_URL}/statuses", "@type": "OrderedCollection", "first": f"{PUBLIC_URL}/{statuspairs[0][1][0]}", "current": f"{PUBLIC_URL}/{statuspairs[-1][1][0]}" }, f, ensure_ascii=False, allow_nan=False) + + # Output topic‐based listings and the topic index + if not exists(f"{PUBLIC_DIRECTORY}/topics"): + mkdir(f"{PUBLIC_DIRECTORY}/topics") + for (topic, ld) in topics.items(): + if not exists(f"{PUBLIC_DIRECTORY}/topics/{topic}"): + mkdir(f"{PUBLIC_DIRECTORY}/topics/{topic}") + with open(f"{PUBLIC_DIRECTORY}/topics/{topic}/index.jsonld", "w", encoding="utf-8") as f: + json.dump(ld, f, ensure_ascii=False, allow_nan=False) + with open(f"{PUBLIC_DIRECTORY}/topics/index.jsonld", "w", encoding="utf-8") as f: + json.dump({ "@context": { "@language": LANG, "activity": "https://www.w3.org/ns/activitystreams#", "dct": "http://purl.org/dc/terms/", "Collection": "activity:Collection", "items": { "@id": "activity:items", "@type": "@id" }, "subject": "dct:subject" }, "@id": f"{PUBLIC_URL}/topics", "@type": "Collection", "items": list(map(lambda a: { "@id": a["@id"], "subject": a["subject"] }, topics.values())) }, f, ensure_ascii=False, allow_nan=False) + + # Remove the build directory. + rmtree(BUILD_DIRECTORY) diff --git a/status.html b/status.html new file mode 100644 index 0000000..a56276e --- /dev/null +++ b/status.html @@ -0,0 +1,94 @@ + + +Status + + + diff --git a/statuses.html b/statuses.html new file mode 100644 index 0000000..60d5aa0 --- /dev/null +++ b/statuses.html @@ -0,0 +1,24 @@ + + +Statuses + +

Statuses

+ + diff --git a/topic.html b/topic.html new file mode 100644 index 0000000..17cb96e --- /dev/null +++ b/topic.html @@ -0,0 +1,72 @@ + + +Topic Feed + +

Topic Feed

+ + diff --git a/topics.html b/topics.html new file mode 100644 index 0000000..b0c5017 --- /dev/null +++ b/topics.html @@ -0,0 +1,20 @@ + + +Topics + +

Topics

+ +