[1mdiff --git a/booster.py b/booster.py[m
[1mindex b56ee8d..8958c45 100755[m
[1m--- a/booster.py[m
[1m+++ b/booster.py[m
[36m@@ -7,7 +7,7 @@[m
##==========================================================================##[m
[m
[31m-# Copyright (c) 2021-2022 Jaakko Keränen jaakko.keranen@iki.fi[m
[32m+[m[32m# Copyright (c) 2021-2025 Jaakko Keränen jaakko.keranen@iki.fi[m
#[m
[36m@@ -30,11 +30,14 @@[m
[m
[32m+[m[32mimport datetime[m
import json[m
import os[m
import subprocess[m
import sys[m
[32m+[m[32mimport time[m
import urllib.parse[m
[32m+[m
pjoin = os.path.join[m
[m
[m
[36m@@ -42,9 +45,11 @@[m [mdef append_slash(p):[m
if not p.endswith('/'): return p + '/'[m
return p[m
[m
[32m+[m
def urlenc(q):[m
return urllib.parse.quote(q)[m
[m
[32m+[m
def run_command(args):[m
try:[m
out = subprocess.check_output(args).decode('utf-8').strip()[m
[36m@@ -56,66 +61,371 @@[m [mdef run_command(args):[m
if len(out):[m
return '```\n' + out + '\n```\n'[m
return ''[m
[31m- [m
[32m+[m
[32m+[m
def report_error(code, msg):[m
print(code, msg + '\r')[m
sys.exit(0)[m
[m
[m
[31m-HOME = os.getenv('HOME')[m
[32m+[m[32mclass Tinylog:[m
[32m+[m[32m TIME_FORMAT = '%Y-%m-%d %H:%M:%S %Z'[m
[32m+[m[32m PATH_FORMAT = '/%Y/%m/%d/%H.%M.%S.%Z'[m
[32m+[m[32m MONTH_FORMAT = '/%Y/%m/'[m
[32m+[m[32m YEAR_FORMAT = '/%Y/'[m
[32m+[m
[32m+[m[32m def init(self, path, editable=False, base_url=''):[m
[32m+[m[32m self.path = path[m
[32m+[m[32m self.title = ''[m
[32m+[m[32m self.info = ''[m
[32m+[m[32m self.author = ''[m
[32m+[m[32m self.avatar = ''[m
[32m+[m[32m self.entries = {} # timestamp -> text[m
[32m+[m[32m self.editable = editable[m
[32m+[m[32m self.base_url = base_url[m
[32m+[m[32m try:[m
[32m+[m[32m self._read()[m
[32m+[m[32m except Exception as er:[m
[32m+[m[32m report_error(50, 'Failed to read Tinylog source: ' + str(er))[m
[32m+[m
[32m+[m[32m def _read(self):[m
[32m+[m[32m with open(self.path, 'r') as f:[m
[32m+[m[32m entry = None[m
[32m+[m[32m entry_ts = None[m
[32m+[m[32m for line in f.readlines():[m
[32m+[m[32m if not entry_ts:[m
[32m+[m[32m if line.startswith('##'):[m
[32m+[m[32m # First entry.[m
[32m+[m[32m entry_ts = time.strptime(line[2:].strip(), Tinylog.TIME_FORMAT)[m
[32m+[m[32m entry = ''[m
[32m+[m[32m elif line.startswith('#'):[m
[32m+[m[32m self.title = line[1:].strip()[m
[32m+[m[32m elif line.startswith('avatar:'):[m
[32m+[m[32m self.avatar = line[7:].strip()[m
[32m+[m[32m elif line.startswith('author:'):[m
[32m+[m[32m self.author = line[7:].strip()[m
[32m+[m[32m else:[m
[32m+[m[32m self.info += line[m
[32m+[m[32m else:[m
[32m+[m[32m if line.startswith('## '):[m
[32m+[m[32m self.entries[time.mktime(entry_ts)] = entry.strip()[m
[32m+[m[32m entry_ts = time.strptime(line[2:].strip(), Tinylog.TIME_FORMAT)[m
[32m+[m[32m entry = ''[m
[32m+[m[32m else:[m
[32m+[m[32m entry += line[m
[32m+[m[32m if entry_ts:[m
[32m+[m[32m # Add the last entry.[m
[32m+[m[32m self.entries[time.mktime(entry_ts)] = entry.strip()[m
[32m+[m
[32m+[m[32m self.info = self.info.strip()[m
[32m+[m
[32m+[m[32m def write(self):[m
[32m+[m[32m with open(self.path, 'w') as f:[m
[32m+[m[32m f.write(self.render(allow_edit=False))[m
[32m+[m[32m # run_command([GIT_COMMAND, 'add', self.path])[m
[32m+[m[32m # run_command([GIT_COMMAND, 'commit', '-m', f'{self.title}: New entry'])[m
[32m+[m
[32m+[m[32m def add(self, timestamp, entry):[m
[32m+[m[32m entry = entry.strip()[m
[32m+[m[32m if len(entry) == 0:[m
[32m+[m[32m report_error(50, "Entries cannot be empty")[m
[32m+[m[32m lines = entry.splitlines()[m
[32m+[m[32m clean = [][m
[32m+[m[32m for line in lines:[m
[32m+[m[32m # Fix headings.[m
[32m+[m[32m if not line.startswith('###') and line.startswith('#'):[m
[32m+[m[32m if line.startswith('##'):[m
[32m+[m[32m line = '###' + line[2:][m
[32m+[m[32m else:[m
[32m+[m[32m line = '###' + line[1:][m
[32m+[m[32m clean.append(line)[m
[32m+[m[32m entry = '\n'.join(clean)[m
[32m+[m[32m self.entries[time.time() if not timestamp else timestamp] = entry[m
[32m+[m
[32m+[m[32m def render(self, allow_edit=True, count=0, entry=None, raw=False, year_filter=None, month_filter=None):[m
[32m+[m[32m is_editable = self.editable and allow_edit[m
[32m+[m[32m is_filtered = year_filter or month_filter[m
[32m+[m[32m output = ''[m
[32m+[m
[32m+[m[32m timestamps = sorted(self.entries.keys(), reverse=True) if not entry else [entry][m
[32m+[m
[32m+[m[32m if count and len(timestamps) > count:[m
[32m+[m[32m archived = list(timestamps)[count:][m
[32m+[m[32m else:[m
[32m+[m[32m archived = [][m
[32m+[m
[32m+[m[32m if not raw:[m
[32m+[m[32m if is_filtered:[m
[32m+[m[32m title = 'Archived: '[m
[32m+[m[32m if month_filter:[m
[32m+[m[32m MONS = ['January', 'February', 'March', 'April', 'May', 'June',[m
[32m+[m[32m 'July', 'August', 'September', 'October', 'November', 'December'][m
[32m+[m[32m output += f'\n# {title}{MONS[month_filter - 1]} {year_filter}\n'[m
[32m+[m[32m else:[m
[32m+[m[32m output += f'\n# {title}{year_filter}\n'[m
[32m+[m
[32m+[m[32m if entry or is_filtered:[m
[32m+[m[32m output += f'\n=> gemini://{self.base_url} {self.title if self.title else "Tinylog"}\n'[m
[32m+[m[32m else:[m
[32m+[m[32m if self.title:[m
[32m+[m[32m output += f'# {self.title}\n\n'[m
[32m+[m[32m if self.info:[m
[32m+[m[32m output += self.info + '\n'[m
[32m+[m
[32m+[m[32m today = datetime.datetime.now()[m
[32m+[m[32m cur_month = None[m
[32m+[m[32m output += '\n'[m
[32m+[m
[32m+[m[32m # Archives are linked to before the entries.[m
[32m+[m[32m for ts in archived:[m
[32m+[m[32m tm = time.localtime(ts)[m
[32m+[m[32m is_recent = tm.tm_year == today.year[m
[32m+[m[32m month = time.strftime(Tinylog.MONTH_FORMAT if is_recent else Tinylog.YEAR_FORMAT, tm)[m
[32m+[m[32m if not cur_month or cur_month != month:[m
[32m+[m[32m cur_month = month[m
[32m+[m[32m label = time.strftime('%B %Y' if is_recent else '%Y', tm)[m
[32m+[m[32m output += f'=> gemini://{self.base_url}{cur_month} Archived: {label}\n'[m
[32m+[m
[32m+[m[32m if self.author or self.avatar:[m
[32m+[m[32m output += f'\nauthor: {self.author}\n'[m
[32m+[m[32m output += f'avatar: {self.avatar}\n'[m
[32m+[m[32m if is_editable:[m
[32m+[m[32m output += f'=> gemini://{self.base_url}/edit Edit metadata\n'[m
[32m+[m[32m output += f'\n=> titan://{self.base_url} New post\n'[m
[32m+[m
[32m+[m[32m index = 0[m
[32m+[m[32m for ts in timestamps if not is_filtered else archived:[m
[32m+[m[32m # Skip entries if they don't pass the filter.[m
[32m+[m[32m if is_filtered:[m
[32m+[m[32m entry_date = datetime.datetime.fromtimestamp(ts)[m
[32m+[m[32m #print(entry_date, file=sys.stderr)[m
[32m+[m[32m if year_filter != entry_date.year or (month_filter and[m
[32m+[m[32m month_filter != entry_date.month):[m
[32m+[m[32m continue[m
[32m+[m
[32m+[m[32m text = self.entries[ts][m
[32m+[m[32m if not raw:[m
[32m+[m[32m output += f'\n## {time.strftime(Tinylog.TIME_FORMAT, time.localtime(ts))}\n'[m
[32m+[m[32m output += text + '\n'[m
[32m+[m[32m if is_editable:[m
[32m+[m[32m tm = time.localtime(ts)[m
[32m+[m[32m entry_path = time.strftime(Tinylog.PATH_FORMAT, tm)[m
[32m+[m[32m if entry: output += '\n'[m
[32m+[m[32m output += f'=> titan://{self.base_url}{entry_path};edit Edit entry\n'[m
[32m+[m[32m if not entry:[m
[32m+[m[32m output += f'=> gemini://{self.base_url}{entry_path} Actions…\n'[m
[32m+[m[32m else:[m
[32m+[m[32m output += f'=> gemini://{self.base_url}{entry_path}?delete ⚠️ Delete\n'[m
[32m+[m[32m index += 1[m
[32m+[m[32m if count and count == index:[m
[32m+[m[32m break[m
[32m+[m
[32m+[m[32m return output[m
[32m+[m
[32m+[m
[32m+[m[32mHOME = os.getenv('HOME')[m
CFG_PATH = pjoin(HOME, '.booster/config.json')[m
[32m+[m
if not os.path.exists(CFG_PATH):[m
print(f'ERROR: Configuration file {CFG_PATH} not found.')[m
sys.exit(1)[m
[31m-CONFIG = json.loads(open(CFG_PATH, 'rt').read())[m
[31m-#print(json.dumps(CONFIG, indent=2))[m
[31m-FILES_ROOT = append_slash(CONFIG['files']['root'])[m
[31m-AUTHORIZED = CONFIG['authorized'] # cert/pubkey fingerprint(s)[m
[31m-[m
[31m-req_identity = os.getenv('REMOTE_IDENT').split(';')[m
[31m-if req_identity[0] not in AUTHORIZED and req_identity[1] not in AUTHORIZED:[m
[31m- report_error(61, 'Not authorized')[m
[31m-req_mime = os.getenv('CONTENT_TYPE')[m
[31m-req_token = os.getenv('TITAN_TOKEN')[m
[31m-path = os.getenv('PATH_INFO')[m
[31m-data = sys.stdin.buffer.read()[m
[31m-[m
[31m-#print(f'Path : {path}')[m
[31m-#print(f'Token : {req_token}')[m
[31m-#print(f'MIME : {req_mime}')[m
[31m-#print(f'Data : {len(data)} bytes')[m
[32m+[m
[32m+[m[32mCONFIG = json.loads(open(CFG_PATH, 'rt').read())[m
[32m+[m[32mSITE_URL = CONFIG['site_url'][m
[32m+[m[32mGIT_COMMAND = CONFIG['git'][m
[32m+[m[32mFILES_ROOT = append_slash(CONFIG['files']['root'])[m
[32m+[m[32mAUTHORIZED = CONFIG['authorized'] # cert/pubkey fingerprint(s)[m
[32m+[m[32mMAX_COUNT = 2[m
[32m+[m
[32m+[m[32mreq_protocol = os.getenv('SERVER_PROTOCOL')[m
[32m+[m[32mreq_identity = os.getenv('REMOTE_IDENT').split(';') if os.getenv('REMOTE_IDENT') else [None, None][m
[32m+[m[32mreq_mime = os.getenv('CONTENT_TYPE')[m
[32m+[m[32mreq_token = os.getenv('TITAN_TOKEN')[m
[32m+[m[32mreq_query = os.getenv('QUERY_STRING')[m
[32m+[m[32mreq_edit = os.getenv('TITAN_EDIT')[m
[32m+[m[32mpath = os.getenv('PATH_INFO')[m
[32m+[m[32mdata = sys.stdin.buffer.read() if req_protocol == 'TITAN' else None[m
[32m+[m
[32m+[m[32mis_authorized = req_identity[0] in AUTHORIZED or req_identity[1] in AUTHORIZED[m
[32m+[m[32mreq_query = urllib.parse.unquote(req_query) if req_query else ''[m
[32m+[m
[32m+[m[32mif req_identity[0] is not None and not is_authorized:[m
[32m+[m[32m report_error(61, "Not authorized")[m
[32m+[m
[32m+[m[32m# print(f'Path : {path}')[m
[32m+[m[32m# print(f'Token : {req_token}')[m
[32m+[m[32m# print(f'MIME : {req_mime}')[m
[32m+[m[32m# print(f'Data : {len(data)} bytes')[m
[32m+[m[32m# print(f'Authd : {is_authorized}')[m
[m
file_path = None[m
[31m-msg_path = None[m
[31m-if (path.startswith('.') or[m
[31m- os.path.basename(path).startswith('.') or[m
[31m- '..' in path):[m
[31m- report_error(61, 'Not authorized')[m
[31m- [m
[31m-# Are we allowed to edit this path?[m
[32m+[m[32mmsg_path = None[m
[32m+[m[32mif path.startswith('.') or os.path.basename(path).startswith('.') or '..' in path:[m
[32m+[m[32m report_error(50, 'Invalid path')[m
[32m+[m
[32m+[m[32m# Check the configuration for this path (editing, mode).[m
msg_prefix = ''[m
[32m+[m[32mis_tinylog = False[m
[32m+[m
for file_group in CONFIG['files']:[m
group = CONFIG['files'][file_group][m
if type(group) != dict: continue[m
[32m+[m
if 'subdir' in group:[m
[32m+[m[32m # Subdirectory containing gemlog posts.[m
auth_prefix = '/' + append_slash(group['subdir'])[m
msg_path = path[len(auth_prefix):][m
elif 'file' in group:[m
[32m+[m[32m # Individual editable file.[m
auth_prefix = '/' + group['file'][m
msg_path = os.path.basename(path)[m
[32m+[m[32m elif 'tinylog' in group:[m
[32m+[m[32m auth_prefix = '/' + group['tinylog'][m
[32m+[m[32m msg_path = os.path.basename(path)[m
[32m+[m[32m is_tinylog = True[m
[32m+[m
[32m+[m[32m file_path = os.path.normpath(pjoin(FILES_ROOT, path))[m
[32m+[m
[32m+[m[32m if is_tinylog:[m
[32m+[m[32m tinylog = Tinylog(pjoin(FILES_ROOT, group['tinylog']),[m
[32m+[m[32m editable=is_authorized,[m
[32m+[m[32m base_url=SITE_URL.replace('gemini://', '') + auth_prefix)[m
[32m+[m
[32m+[m[32m if req_protocol == 'GEMINI':[m
[32m+[m[32m if path == '/':[m
[32m+[m[32m report_error(31, 'gemini://' + tinylog.base_url)[m
[32m+[m
[32m+[m[32m # TODO: Check author/avatar queries.[m
[32m+[m[32m if path == '/edit' and is_authorized:[m
[32m+[m[32m print('20 text/gemini;charset=utf-8\r')[m
[32m+[m[32m print('# Edit Tinylog\n')[m
[32m+[m[32m print(tinylog.title if tinylog.title else 'Untitled')[m
[32m+[m[32m print(f'=> gemini://{tinylog.base_url}/title Edit title')[m
[32m+[m[32m print()[m
[32m+[m[32m print(tinylog.info if tinylog.info else 'No description.')[m
[32m+[m[32m print(f'=> titan://{tinylog.base_url}/info;edit Edit info')[m
[32m+[m[32m print()[m
[32m+[m[32m print(tinylog.author if tinylog.author else 'anonymous')[m
[32m+[m[32m print(f'=> gemini://{tinylog.base_url}/author Edit author')[m
[32m+[m[32m print()[m
[32m+[m[32m print(tinylog.avatar)[m
[32m+[m[32m print(f'=> gemini://{tinylog.base_url}/avatar Edit avatar')[m
[32m+[m[32m sys.exit(0)[m
[32m+[m
[32m+[m[32m if path == '/title' and is_authorized:[m
[32m+[m[32m if not req_query:[m
[32m+[m[32m report_error(10, 'Enter title:')[m
[32m+[m[32m elif req_query == '-':[m
[32m+[m[32m req_query = ''[m
[32m+[m[32m else:[m
[32m+[m[32m req_query = req_query.strip()[m
[32m+[m[32m tinylog.title = req_query[m
[32m+[m[32m tinylog.write()[m
[32m+[m[32m report_error(30, f'gemini://{tinylog.base_url}/edit')[m
[32m+[m
[32m+[m[32m if path == '/author' and is_authorized:[m
[32m+[m[32m if not req_query:[m
[32m+[m[32m report_error(10, 'Enter author name:')[m
[32m+[m[32m elif req_query == '-':[m
[32m+[m[32m req_query = ''[m
[32m+[m[32m else:[m
[32m+[m[32m req_query = req_query.strip()[m
[32m+[m[32m tinylog.author = req_query[m
[32m+[m[32m tinylog.write()[m
[32m+[m[32m report_error(30, f'gemini://{tinylog.base_url}/edit')[m
[32m+[m
[32m+[m[32m if path == '/avatar' and is_authorized:[m
[32m+[m[32m if not req_query:[m
[32m+[m[32m report_error(10, 'Enter avatar symbol:')[m
[32m+[m[32m elif req_query == '-':[m
[32m+[m[32m req_query = ''[m
[32m+[m[32m else:[m
[32m+[m[32m req_query = req_query.strip()[m
[32m+[m[32m tinylog.avatar = req_query[m
[32m+[m[32m tinylog.write()[m
[32m+[m[32m report_error(30, f'gemini://{tinylog.base_url}/edit')[m
[32m+[m
[32m+[m[32m if req_edit and is_authorized:[m
[32m+[m[32m if path == '/info':[m
[32m+[m[32m print('20 text/gemini;charset=utf-8\r\n' + tinylog.info)[m
[32m+[m[32m sys.exit(0)[m
[32m+[m
[32m+[m[32m entry_ts = time.mktime(time.strptime(path, Tinylog.PATH_FORMAT))[m
[32m+[m[32m entry = tinylog.entries[entry_ts][m
[32m+[m[32m print('20 text/gemini;charset=utf-8\r\n' +[m
[32m+[m[32m tinylog.render(entry=entry_ts, allow_edit=False, raw=True))[m
[32m+[m[32m sys.exit(0)[m
[32m+[m
[32m+[m[32m # Entry page.[m
[32m+[m[32m try:[m
[32m+[m[32m entry_ts = time.mktime(time.strptime(path, Tinylog.PATH_FORMAT))[m
[32m+[m[32m if entry_ts in tinylog.entries:[m
[32m+[m[32m if req_query == 'delete':[m
[32m+[m[32m report_error(10, 'Really DELETE entry?')[m
[32m+[m[32m elif req_query == 'DELETE':[m
[32m+[m[32m del tinylog.entries[entry_ts][m
[32m+[m[32m tinylog.write()[m
[32m+[m[32m report_error(30, tinylog.base_url)[m
[32m+[m[32m else:[m
[32m+[m[32m print('20 text/gemini;charset=utf-8\r\n')[m
[32m+[m[32m print(tinylog.render(entry=entry_ts))[m
[32m+[m[32m sys.exit(0)[m
[32m+[m[32m except Exception as er:[m
[32m+[m[32m #print(er, file=sys.stderr)[m
[32m+[m[32m # Try a monthly archive filter.[m
[32m+[m[32m try:[m
[32m+[m[32m filter = time.strptime(path, Tinylog.MONTH_FORMAT)[m
[32m+[m[32m print('20 text/gemini;charset=utf-8\r\n' + tinylog.render(count=MAX_COUNT, month_filter=filter.tm_mon, year_filter=filter.tm_year))[m
[32m+[m[32m sys.exit(0)[m
[32m+[m[32m except Exception as er:[m
[32m+[m[32m print(er, file=sys.stderr)[m
[32m+[m
[32m+[m[32m try:[m
[32m+[m[32m filter = time.strptime(path, Tinylog.YEAR_FORMAT)[m
[32m+[m[32m print('20 text/gemini;charset=utf-8\r\n' + tinylog.render(count=MAX_COUNT, year_filter=filter.tm_year))[m
[32m+[m[32m sys.exit(0)[m
[32m+[m[32m except Exception as er:[m
[32m+[m[32m print(er, file=sys.stderr)[m
[32m+[m
[32m+[m[32m print('20 text/gemini;charset=utf-8\r\n')[m
[32m+[m[32m print(tinylog.render(count=MAX_COUNT))[m
[32m+[m
[32m+[m[32m else:[m
[32m+[m[32m # Post a new entry or edit an existing one.[m
[32m+[m[32m if not is_authorized:[m
[32m+[m[32m report_error(61, "Not authorized")[m
[32m+[m
[32m+[m[32m data_text = data.decode('utf-8')[m
[32m+[m
[32m+[m[32m if path == '/info':[m
[32m+[m[32m tinylog.info = data_text.strip()[m
[32m+[m[32m tinylog.write()[m
[32m+[m[32m report_error(30, 'gemini://' + tinylog.base_url + '/edit')[m
[32m+[m[32m else:[m
[32m+[m[32m try:[m
[32m+[m[32m entry_ts = time.mktime(time.strptime(path, Tinylog.PATH_FORMAT))[m
[32m+[m[32m except:[m
[32m+[m[32m entry_ts = None[m
[32m+[m[32m tinylog.add(entry_ts, data_text)[m
[32m+[m[32m tinylog.write()[m
[32m+[m[32m report_error(30, 'gemini://' + tinylog.base_url +[m
[32m+[m[32m (time.strftime(Tinylog.PATH_FORMAT, time.localtime(entry_ts))[m
[32m+[m[32m if entry_ts else ''))[m
[32m+[m[41m [m
[32m+[m[32m sys.exit(0)[m
[32m+[m
if path.startswith(auth_prefix):[m
[31m- file_path = os.path.normpath(FILES_ROOT + path)[m
[31m- if not file_path.startswith(FILES_ROOT) or \[m
[31m- os.path.isdir(file_path):[m
[32m+[m[32m if not file_path.startswith(FILES_ROOT) or os.path.isdir(file_path):[m
report_error(61, 'Not authorized')[m
msg_prefix = f'{file_group}: '[m
break[m
[32m+[m
if not file_path:[m
report_error(61, "Unauthorized location")[m
[m
is_new = not os.path.exists(file_path)[m
[31m-view_url = '%s%s' % (CONFIG['site_url'], path)[m
[32m+[m[32mview_url = '%s%s' % (SITE_URL, path)[m
response = '# Booster log\n'[m
if not is_new and req_token == 'DELETE':[m
response += f'* deleting file: {file_path}\n'[m
[36m@@ -141,8 +451,8 @@[m [mresponse += f'* updating indices\n'[m
response += run_command([CONFIG['python'], ".makeindex.py"])[m
response += '* committing changes to Git repository\n'[m
if is_new:[m
[31m- response += run_command([CONFIG['git'], 'add', file_path])[m
[31m-response += run_command([CONFIG['git'], 'commit', '-a', '-m', git_msg])[m
[32m+[m[32m response += run_command([GIT_COMMAND, 'add', file_path])[m
[32m+[m[32mresponse += run_command([GIT_COMMAND, 'commit', '-a', '-m', git_msg])[m
response += "\n"[m
response += f"=> {view_url} View the page\n"[m
response += f"=> gemini://warmedal.se/~antenna/submit?%s Notify Antenna" %\[m
text/plain
This content has been proxied by September (3851b).