diff --git a/booster.py b/booster.py

index b56ee8d..8958c45 100755

--- a/booster.py

+++ b/booster.py

@@ -7,7 +7,7 @@

##

##==========================================================================##



-# Copyright (c) 2021-2022 Jaakko Keränen jaakko.keranen@iki.fi

+# Copyright (c) 2021-2025 Jaakko Keränen jaakko.keranen@iki.fi

#

Redistribution and use in source and binary forms, with or without

modification, are permitted provided that the following conditions are met:

@@ -30,11 +30,14 @@

ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE

POSSIBILITY OF SUCH DAMAGE.



+import datetime

import json

import os

import subprocess

import sys

+import time

import urllib.parse

+

pjoin = os.path.join





@@ -42,9 +45,11 @@ def append_slash(p):

 if not p.endswith('/'): return p + '/'

 return p



+

def urlenc(q):

 return urllib.parse.quote(q)



+

def run_command(args):

 try:

     out = subprocess.check_output(args).decode('utf-8').strip()

@@ -56,66 +61,371 @@ def run_command(args):

 if len(out):

     return '```\n' + out + '\n```\n'

 return ''

- 

+

+

def report_error(code, msg):

 print(code, msg + '\r')

 sys.exit(0)





-HOME = os.getenv('HOME')

+class Tinylog:

+ TIME_FORMAT = '%Y-%m-%d %H:%M:%S %Z'

+ PATH_FORMAT = '/%Y/%m/%d/%H.%M.%S.%Z'

+ MONTH_FORMAT = '/%Y/%m/'

+ YEAR_FORMAT = '/%Y/'

+

+ def init(self, path, editable=False, base_url=''):

+ self.path = path

+ self.title = ''

+ self.info = ''

+ self.author = ''

+ self.avatar = ''

+ self.entries = {} # timestamp -> text

+ self.editable = editable

+ self.base_url = base_url

+ try:

+ self._read()

+ except Exception as er:

+ report_error(50, 'Failed to read Tinylog source: ' + str(er))

+

+ def _read(self):

+ with open(self.path, 'r') as f:

+ entry = None

+ entry_ts = None

+ for line in f.readlines():

+ if not entry_ts:

+ if line.startswith('##'):

+ # First entry.

+ entry_ts = time.strptime(line[2:].strip(), Tinylog.TIME_FORMAT)

+ entry = ''

+ elif line.startswith('#'):

+ self.title = line[1:].strip()

+ elif line.startswith('avatar:'):

+ self.avatar = line[7:].strip()

+ elif line.startswith('author:'):

+ self.author = line[7:].strip()

+ else:

+ self.info += line

+ else:

+ if line.startswith('## '):

+ self.entries[time.mktime(entry_ts)] = entry.strip()

+ entry_ts = time.strptime(line[2:].strip(), Tinylog.TIME_FORMAT)

+ entry = ''

+ else:

+ entry += line

+ if entry_ts:

+ # Add the last entry.

+ self.entries[time.mktime(entry_ts)] = entry.strip()

+

+ self.info = self.info.strip()

+

+ def write(self):

+ with open(self.path, 'w') as f:

+ f.write(self.render(allow_edit=False))

+ # run_command([GIT_COMMAND, 'add', self.path])

+ # run_command([GIT_COMMAND, 'commit', '-m', f'{self.title}: New entry'])

+

+ def add(self, timestamp, entry):

+ entry = entry.strip()

+ if len(entry) == 0:

+ report_error(50, "Entries cannot be empty")

+ lines = entry.splitlines()

+ clean = []

+ for line in lines:

+ # Fix headings.

+ if not line.startswith('###') and line.startswith('#'):

+ if line.startswith('##'):

+ line = '###' + line[2:]

+ else:

+ line = '###' + line[1:]

+ clean.append(line)

+ entry = '\n'.join(clean)

+ self.entries[time.time() if not timestamp else timestamp] = entry

+

+ def render(self, allow_edit=True, count=0, entry=None, raw=False, year_filter=None, month_filter=None):

+ is_editable = self.editable and allow_edit

+ is_filtered = year_filter or month_filter

+ output = ''

+

+ timestamps = sorted(self.entries.keys(), reverse=True) if not entry else [entry]

+

+ if count and len(timestamps) > count:

+ archived = list(timestamps)[count:]

+ else:

+ archived = []

+

+ if not raw:

+ if is_filtered:

+ title = 'Archived: '

+ if month_filter:

+ MONS = ['January', 'February', 'March', 'April', 'May', 'June',

+ 'July', 'August', 'September', 'October', 'November', 'December']

+ output += f'\n# {title}{MONS[month_filter - 1]} {year_filter}\n'

+ else:

+ output += f'\n# {title}{year_filter}\n'

+

+ if entry or is_filtered:

+ output += f'\n=> gemini://{self.base_url} {self.title if self.title else "Tinylog"}\n'

+ else:

+ if self.title:

+ output += f'# {self.title}\n\n'

+ if self.info:

+ output += self.info + '\n'

+

+ today = datetime.datetime.now()

+ cur_month = None

+ output += '\n'

+

+ # Archives are linked to before the entries.

+ for ts in archived:

+ tm = time.localtime(ts)

+ is_recent = tm.tm_year == today.year

+ month = time.strftime(Tinylog.MONTH_FORMAT if is_recent else Tinylog.YEAR_FORMAT, tm)

+ if not cur_month or cur_month != month:

+ cur_month = month

+ label = time.strftime('%B %Y' if is_recent else '%Y', tm)

+ output += f'=> gemini://{self.base_url}{cur_month} Archived: {label}\n'

+

+ if self.author or self.avatar:

+ output += f'\nauthor: {self.author}\n'

+ output += f'avatar: {self.avatar}\n'

+ if is_editable:

+ output += f'=> gemini://{self.base_url}/edit Edit metadata\n'

+ output += f'\n=> titan://{self.base_url} New post\n'

+

+ index = 0

+ for ts in timestamps if not is_filtered else archived:

+ # Skip entries if they don't pass the filter.

+ if is_filtered:

+ entry_date = datetime.datetime.fromtimestamp(ts)

+ #print(entry_date, file=sys.stderr)

+ if year_filter != entry_date.year or (month_filter and

+ month_filter != entry_date.month):

+ continue

+

+ text = self.entries[ts]

+ if not raw:

+ output += f'\n## {time.strftime(Tinylog.TIME_FORMAT, time.localtime(ts))}\n'

+ output += text + '\n'

+ if is_editable:

+ tm = time.localtime(ts)

+ entry_path = time.strftime(Tinylog.PATH_FORMAT, tm)

+ if entry: output += '\n'

+ output += f'=> titan://{self.base_url}{entry_path};edit Edit entry\n'

+ if not entry:

+ output += f'=> gemini://{self.base_url}{entry_path} Actions…\n'

+ else:

+ output += f'=> gemini://{self.base_url}{entry_path}?delete ⚠️ Delete\n'

+ index += 1

+ if count and count == index:

+ break

+

+ return output

+

+

+HOME = os.getenv('HOME')

CFG_PATH = pjoin(HOME, '.booster/config.json')

+

if not os.path.exists(CFG_PATH):

 print(f'ERROR: Configuration file {CFG_PATH} not found.')

 sys.exit(1)

-CONFIG = json.loads(open(CFG_PATH, 'rt').read())

-#print(json.dumps(CONFIG, indent=2))

-FILES_ROOT = append_slash(CONFIG['files']['root'])

-AUTHORIZED = CONFIG['authorized'] # cert/pubkey fingerprint(s)

-

-req_identity = os.getenv('REMOTE_IDENT').split(';')

-if req_identity[0] not in AUTHORIZED and req_identity[1] not in AUTHORIZED:

- report_error(61, 'Not authorized')

-req_mime = os.getenv('CONTENT_TYPE')

-req_token = os.getenv('TITAN_TOKEN')

-path = os.getenv('PATH_INFO')

-data = sys.stdin.buffer.read()

-

-#print(f'Path : {path}')

-#print(f'Token : {req_token}')

-#print(f'MIME : {req_mime}')

-#print(f'Data : {len(data)} bytes')

+

+CONFIG = json.loads(open(CFG_PATH, 'rt').read())

+SITE_URL = CONFIG['site_url']

+GIT_COMMAND = CONFIG['git']

+FILES_ROOT = append_slash(CONFIG['files']['root'])

+AUTHORIZED = CONFIG['authorized'] # cert/pubkey fingerprint(s)

+MAX_COUNT = 2

+

+req_protocol = os.getenv('SERVER_PROTOCOL')

+req_identity = os.getenv('REMOTE_IDENT').split(';') if os.getenv('REMOTE_IDENT') else [None, None]

+req_mime = os.getenv('CONTENT_TYPE')

+req_token = os.getenv('TITAN_TOKEN')

+req_query = os.getenv('QUERY_STRING')

+req_edit = os.getenv('TITAN_EDIT')

+path = os.getenv('PATH_INFO')

+data = sys.stdin.buffer.read() if req_protocol == 'TITAN' else None

+

+is_authorized = req_identity[0] in AUTHORIZED or req_identity[1] in AUTHORIZED

+req_query = urllib.parse.unquote(req_query) if req_query else ''

+

+if req_identity[0] is not None and not is_authorized:

+ report_error(61, "Not authorized")

+

+# print(f'Path : {path}')

+# print(f'Token : {req_token}')

+# print(f'MIME : {req_mime}')

+# print(f'Data : {len(data)} bytes')

+# print(f'Authd : {is_authorized}')



file_path = None

-msg_path = None

-if (path.startswith('.') or

- os.path.basename(path).startswith('.') or

- '..' in path):

- report_error(61, 'Not authorized')

- 

-# Are we allowed to edit this path?

+msg_path = None

+if path.startswith('.') or os.path.basename(path).startswith('.') or '..' in path:

+ report_error(50, 'Invalid path')

+

+# Check the configuration for this path (editing, mode).

msg_prefix = ''

+is_tinylog = False

+

for file_group in CONFIG['files']:

 group = CONFIG['files'][file_group]

 if type(group) != dict: continue

+

 if 'subdir' in group:

+ # Subdirectory containing gemlog posts.

     auth_prefix = '/' + append_slash(group['subdir'])

     msg_path    = path[len(auth_prefix):]

 elif 'file' in group:

+ # Individual editable file.

     auth_prefix = '/' + group['file']

     msg_path    = os.path.basename(path)

+ elif 'tinylog' in group:

+ auth_prefix = '/' + group['tinylog']

+ msg_path = os.path.basename(path)

+ is_tinylog = True

+

+ file_path = os.path.normpath(pjoin(FILES_ROOT, path))

+

+ if is_tinylog:

+ tinylog = Tinylog(pjoin(FILES_ROOT, group['tinylog']),

+ editable=is_authorized,

+ base_url=SITE_URL.replace('gemini://', '') + auth_prefix)

+

+ if req_protocol == 'GEMINI':

+ if path == '/':

+ report_error(31, 'gemini://' + tinylog.base_url)

+

+ # TODO: Check author/avatar queries.

+ if path == '/edit' and is_authorized:

+ print('20 text/gemini;charset=utf-8\r')

+ print('# Edit Tinylog\n')

+ print(tinylog.title if tinylog.title else 'Untitled')

+ print(f'=> gemini://{tinylog.base_url}/title Edit title')

+ print()

+ print(tinylog.info if tinylog.info else 'No description.')

+ print(f'=> titan://{tinylog.base_url}/info;edit Edit info')

+ print()

+ print(tinylog.author if tinylog.author else 'anonymous')

+ print(f'=> gemini://{tinylog.base_url}/author Edit author')

+ print()

+ print(tinylog.avatar)

+ print(f'=> gemini://{tinylog.base_url}/avatar Edit avatar')

+ sys.exit(0)

+

+ if path == '/title' and is_authorized:

+ if not req_query:

+ report_error(10, 'Enter title:')

+ elif req_query == '-':

+ req_query = ''

+ else:

+ req_query = req_query.strip()

+ tinylog.title = req_query

+ tinylog.write()

+ report_error(30, f'gemini://{tinylog.base_url}/edit')

+

+ if path == '/author' and is_authorized:

+ if not req_query:

+ report_error(10, 'Enter author name:')

+ elif req_query == '-':

+ req_query = ''

+ else:

+ req_query = req_query.strip()

+ tinylog.author = req_query

+ tinylog.write()

+ report_error(30, f'gemini://{tinylog.base_url}/edit')

+

+ if path == '/avatar' and is_authorized:

+ if not req_query:

+ report_error(10, 'Enter avatar symbol:')

+ elif req_query == '-':

+ req_query = ''

+ else:

+ req_query = req_query.strip()

+ tinylog.avatar = req_query

+ tinylog.write()

+ report_error(30, f'gemini://{tinylog.base_url}/edit')

+

+ if req_edit and is_authorized:

+ if path == '/info':

+ print('20 text/gemini;charset=utf-8\r\n' + tinylog.info)

+ sys.exit(0)

+

+ entry_ts = time.mktime(time.strptime(path, Tinylog.PATH_FORMAT))

+ entry = tinylog.entries[entry_ts]

+ print('20 text/gemini;charset=utf-8\r\n' +

+ tinylog.render(entry=entry_ts, allow_edit=False, raw=True))

+ sys.exit(0)

+

+ # Entry page.

+ try:

+ entry_ts = time.mktime(time.strptime(path, Tinylog.PATH_FORMAT))

+ if entry_ts in tinylog.entries:

+ if req_query == 'delete':

+ report_error(10, 'Really DELETE entry?')

+ elif req_query == 'DELETE':

+ del tinylog.entries[entry_ts]

+ tinylog.write()

+ report_error(30, tinylog.base_url)

+ else:

+ print('20 text/gemini;charset=utf-8\r\n')

+ print(tinylog.render(entry=entry_ts))

+ sys.exit(0)

+ except Exception as er:

+ #print(er, file=sys.stderr)

+ # Try a monthly archive filter.

+ try:

+ filter = time.strptime(path, Tinylog.MONTH_FORMAT)

+ print('20 text/gemini;charset=utf-8\r\n' + tinylog.render(count=MAX_COUNT, month_filter=filter.tm_mon, year_filter=filter.tm_year))

+ sys.exit(0)

+ except Exception as er:

+ print(er, file=sys.stderr)

+

+ try:

+ filter = time.strptime(path, Tinylog.YEAR_FORMAT)

+ print('20 text/gemini;charset=utf-8\r\n' + tinylog.render(count=MAX_COUNT, year_filter=filter.tm_year))

+ sys.exit(0)

+ except Exception as er:

+ print(er, file=sys.stderr)

+

+ print('20 text/gemini;charset=utf-8\r\n')

+ print(tinylog.render(count=MAX_COUNT))

+

+ else:

+ # Post a new entry or edit an existing one.

+ if not is_authorized:

+ report_error(61, "Not authorized")

+

+ data_text = data.decode('utf-8')

+

+ if path == '/info':

+ tinylog.info = data_text.strip()

+ tinylog.write()

+ report_error(30, 'gemini://' + tinylog.base_url + '/edit')

+ else:

+ try:

+ entry_ts = time.mktime(time.strptime(path, Tinylog.PATH_FORMAT))

+ except:

+ entry_ts = None

+ tinylog.add(entry_ts, data_text)

+ tinylog.write()

+ report_error(30, 'gemini://' + tinylog.base_url +

+ (time.strftime(Tinylog.PATH_FORMAT, time.localtime(entry_ts))

+ if entry_ts else ''))

+ 

+ sys.exit(0)

+

 if path.startswith(auth_prefix):

- file_path = os.path.normpath(FILES_ROOT + path)

- if not file_path.startswith(FILES_ROOT) or \

- os.path.isdir(file_path):

+ if not file_path.startswith(FILES_ROOT) or os.path.isdir(file_path):

         report_error(61, 'Not authorized')

     msg_prefix  = f'{file_group}: '

     break

+

if not file_path:

 report_error(61, "Unauthorized location")



Process the request.

is_new = not os.path.exists(file_path)

-view_url = '%s%s' % (CONFIG['site_url'], path)

+view_url = '%s%s' % (SITE_URL, path)

response = '# Booster log\n'

if not is_new and req_token == 'DELETE':

 response += f'* deleting file: {file_path}\n'

@@ -141,8 +451,8 @@ response += f'* updating indices\n'

response += run_command([CONFIG['python'], ".makeindex.py"])

response += '* committing changes to Git repository\n'

if is_new:

- response += run_command([CONFIG['git'], 'add', file_path])

-response += run_command([CONFIG['git'], 'commit', '-a', '-m', git_msg])

+ response += run_command([GIT_COMMAND, 'add', file_path])

+response += run_command([GIT_COMMAND, 'commit', '-a', '-m', git_msg])

response += "\n"

response += f"=> {view_url} View the page\n"

response += f"=> gemini://warmedal.se/~antenna/submit?%s Notify Antenna" %\

Proxy Information
Original URL
gemini://git.skyjake.fi/booster/main/pcdiff/4d5414f7ab7888f0bd005356d4a8d62a2177a9b2
Status Code
Success (20)
Meta
text/plain
Capsule Response Time
32.226978 milliseconds
Gemini-to-HTML Time
11.11327 milliseconds

This content has been proxied by September (3851b).