home » zplus/clif.git
Author zPlus <zplus@peers.community> 2022-07-27 09:02:11
Committer zPlus <zplus@peers.community> 2022-07-27 09:02:11
Commit d9113a6 (patch)
Tree 86910f9
Parent(s)

Initial commit. This is the first working version of CLIF.


commits diff: 0000000..d9113a6
21 files changed, 1901 insertions, 0 deletionsdownload


Diffstat
-rw-r--r-- .gitignore 2
-rw-r--r-- README 149
-rwxr-xr-x emails.py 238
-rw-r--r-- requirements.txt 8
-rw-r--r-- static/css/clif.css 285
-rw-r--r-- static/css/pygments_default.css 80
-rw-r--r-- templates/about.html 56
-rw-r--r-- templates/explore.html 25
-rw-r--r-- templates/index.html 19
-rw-r--r-- templates/layout.html 21
-rw-r--r-- templates/mailing_list/emails.html 18
-rw-r--r-- templates/mailing_list/emails_thread.html 54
-rw-r--r-- templates/mailing_list/mailing_list.html 11
-rw-r--r-- templates/repository/blob.html 30
-rw-r--r-- templates/repository/log.html 75
-rw-r--r-- templates/repository/readme.html 36
-rw-r--r-- templates/repository/refs.html 27
-rw-r--r-- templates/repository/repository.html 30
-rw-r--r-- templates/repository/tree.html 54
-rw-r--r-- web.py 670
-rw-r--r-- web.service 13

Diff options
View
Side
Whitespace
Context lines
Inter-hunk lines
+2/-0 A   .gitignore
index 0000000..82adb58
old size: 0B - new size: 17B
new file mode: -rw-r--r--
@@ -0,0 +1,2 @@
1 + __pycache__
2 + venv

+149/-0 A   README
index 0000000..cb4bfb2
old size: 0B - new size: 4K
new file mode: -rw-r--r--
@@ -0,0 +1,149 @@
1 + # Installation
2 +
3 + 1. Install Gitolite
4 +
5 + Follow instructions at https://gitolite.com/gitolite/fool_proof_setup.html
6 + When Gitolite is installed, clone the gitolite-admin repository and add this to
7 + conf/gitolite.conf:
8 +
9 + repo CREATOR/..*
10 + C = @all
11 + RW+ = CREATOR
12 + R = @all
13 +
14 + The rule above will allow any registered user (@all) to create a repository. CREATOR
15 + is a gitolite keywords and it's replaced with the username who created the repo.
16 + To create a new repository, just use "git clone git@domain:username/reponame".
17 + Since the regexp CREATOR/..* will replace CREATOR with the user name, the logged
18 + in user will be allowed to create new repositories *only* under their username.
19 +
20 + Adding new users is as simple as adding their key to gitolite-admin/keydir/<username>.pub
21 +
22 + Gitolite does not do authentication, it only does authorization. The name of the
23 + logged in user is provided as an environment variable. In order to allow anonymous
24 + HTTP clones, ie. for allowing "git clone https://..." without any authentication,
25 + the web app automatically sets a generic username value of "anonymous". We need
26 + to let Gitolite know what the unauthenticated user is going to be called so that
27 + it can check authorization. To do this, just add the following to ~/.gitolite.rc
28 + in the section marked "rc variables used by various features". This is explained
29 + at https://gitolite.com/gitolite/http.html#allowing-unauthenticated-access
30 +
31 + HTTP_ANON_USER => 'anonymous',
32 +
33 + Enable some non-core commands that are useful to us. This is done by editing ~/.gitolite.rc:
34 +
35 + 'ENABLE' => [
36 + ... existing commands
37 +
38 + # Allow to change HEAD reference (default branch) like this:
39 + # ssh git@host symbolic-ref <repo> HEAD refs/heads/<name>
40 + 'symbolic-ref',
41 + ]
42 +
43 +
44 + 2. Emails
45 +
46 + Start by downloading the clif repository:
47 +
48 + git clone <clif-url> /home/git
49 +
50 + Change the settings inside the emails.py file.
51 +
52 + Add the following to /etc/postfix/main.cf. This will forward all emails to the
53 + system user "git"
54 +
55 + luser_relay = git
56 + local_recipient_maps =
57 +
58 + Then add the following to /home/git/.forward. ".forward" is a sendmail file, also
59 + used by postfix, used for deciding how to deliver the message the the system user.
60 + For our purposes, we instruct postfix to pipe all the emails for user "git" to our
61 + script:
62 +
63 + |/home/git/clif/emails.py
64 +
65 + make sure the script is executable:
66 +
67 + chmod +x /home/git/clif/emails.py
68 +
69 + 3. Web UI
70 +
71 + Start by downloading the clif repository:
72 +
73 + git clone <clif-url> /home/git
74 +
75 + Install the requirements:
76 +
77 + cd /home/git/clif
78 + python3 -m venv venv
79 + source venv/bin/activate
80 + pip install -r requirements.txt
81 +
82 + Change the settings inside the web.py file.
83 +
84 + Install a SystemD service:
85 +
86 + cp web.service /etc/systemd/system/clif-web.service
87 + systemctl daemon-reload
88 + systemctl enable clif-web
89 + systemctl start clif-web
90 +
91 + 4. TLS certificate
92 +
93 + Now we create a new TLS certificate for supporting HTTPS connections:
94 +
95 + apt-get install certbot
96 + certbot certonly --webroot -w /var/www/html -d domain.tld
97 +
98 + The cert is created in /etc/letsencrypt/live/<domain.tld>/
99 +
100 + Lighttpd requires the certificate and private key to be in a single file:
101 +
102 + cat privkey.pem cert.pem > privkey+cert.pem
103 +
104 + Configure lighttpd reverse proxy:
105 +
106 + vim /etc/lighttpd/lighttpd.conf
107 +
108 + server.modules += (
109 + "mod_fastcgi",
110 + "mod_proxy",
111 + )
112 +
113 + $HTTP["scheme"] == "http" {
114 + url.redirect = ("" => "https://${url.authority}${url.path}${qsa}")
115 + url.redirect-code = 308
116 + }
117 +
118 + $SERVER["socket"] == ":443" {
119 + ssl.engine = "enable"
120 + ssl.pemfile = "/etc/letsencrypt/live/<domain.tld>/privkey+cert.pem"
121 + ssl.ca-file = "/etc/letsencrypt/live/<domain.tld>/chain.pem"
122 +
123 + $HTTP["host"] == "<domain.tld>" {
124 + proxy.server = (
125 + "" => (
126 + ( "host" => "127.0.0.1", "port" => 5000 )
127 + )
128 + )
129 + # server.document-root = "/var/www/html" # Document Root
130 + # server.errorlog = "/"
131 + # accesslog.filename = "/"
132 + }
133 + }
134 +
135 + Let's Encrypt certificates expire every 90 days, so we need to setup a cron job
136 + that will generate a new privkey+cert.pem file, and reload lighttpd too.
137 +
138 + vim /etc/cron.weekly/clif-letsencrypt
139 + chmod +x /etc/cron.weekly/clif-letsencrypt
140 +
141 + certbot renew
142 + cd /etc/letsencrypt/live/<domain.tld>
143 + cat privkey.pem cert.pem > privkey+cert.pem
144 + service lighttpd restart
145 +
146 + # Development
147 +
148 + gunicorn --reload --bind localhost:5000 web:application
149 +

+238/-0 A   emails.py
index 0000000..01b2487
old size: 0B - new size: 9K
new file mode: -rwxr-xr-x
@@ -0,0 +1,238 @@
1 + #!/home/git/clif/venv/bin/python
2 +
3 + ###############################################################################
4 + # This script is called by Postfix every time it receives an email.
5 + # This script will accept incoming emails and add them to the mailing lists repositories.
6 + ###############################################################################
7 +
8 + import datetime
9 + import email
10 + import email.policy
11 + import hashlib
12 + import json
13 + import logging
14 + import os
15 + import pygit2
16 + import smtplib
17 + import sys
18 +
19 +
20 +
21 +
22 + ###############################################################################
23 + # SETTINGS
24 + ###############################################################################
25 +
26 + # Default options for the configuration file
27 + configuration = {
28 + 'enabled': True,
29 + 'subscribers': []
30 + }
31 +
32 + # The "domain" part in address@domain that we're expecting to see.
33 + # All emails addressed to another domain will be ignored.
34 + SERVER_DOMAIN = 'domain.local'
35 +
36 + REPOSITORIES_PATH = '/home/git/repositories'
37 +
38 + # Level | Numeric value
39 + # ---------|--------------
40 + # CRITICAL | 50
41 + # ERROR | 40
42 + # WARNING | 30
43 + # INFO | 20
44 + # DEBUG | 10
45 + # NOTSET | 0
46 + logging.basicConfig(filename='/home/git/clif/emails.log',
47 + level=logging.NOTSET,
48 + format='[%(asctime)s] %(levelname)s - %(message)s',
49 + datefmt='%Y-%m-%d %H:%M:%S%z')
50 +
51 +
52 +
53 +
54 + ###############################################################################
55 + # ACCEPT/VALIDATE INCOMING EMAIL
56 + ###############################################################################
57 +
58 + # Retrieve the email message from stdin (Postfix has piped this script)
59 + message_raw = sys.stdin.read()
60 + message = email.message_from_string(message_raw, policy=email.policy.default)
61 +
62 + email_id = message.get('message-id')
63 + email_id_hash = hashlib.sha256(email_id.encode('utf-8')).hexdigest()[:8] # This will be used as thread ID
64 + email_from = email.utils.parseaddr(message.get('from'))
65 + email_to = email.utils.parseaddr(message.get('to'))
66 + email_in_reply_to = message.get('in-reply-to')
67 + email_subject = message.get('subject')
68 + email_body = message.get_body(('plain',)).get_content() # Accept plaintext only!
69 +
70 + logging.info('Received email from {} to {} with subject {}'.format(email_from, email_to, email_subject))
71 +
72 + if not email_id:
73 + logging.info('Refuting email without a Message-ID: {}'.format(email_subject))
74 + exit()
75 +
76 + if not email_body:
77 + logging.warning('Refuting email without plaintext body: {}'.format(email_subject))
78 + exit()
79 +
80 + if not email_to[1].endswith('@' + SERVER_DOMAIN):
81 + logging.warning('Refuting email with bad recipient domain: {}'.format(email_to))
82 + exit()
83 +
84 + # Get the repository name. We use email addresses formatted as <repository>@SERVER_DOMAIN
85 + repository_name = email_to[1].rsplit('@', 1)[0]
86 + repository_path = os.path.join(REPOSITORIES_PATH, repository_name + '.mlist.git')
87 +
88 + if '..' in repository_name:
89 + logging.warning('Refuting email because the repository name contains "..": {}'.format(repository_name))
90 + exit()
91 +
92 + if '/' not in repository_name:
93 + logging.warning('Refuting email because the repository name does not contain a namespace: {}'.format(repository_name))
94 + exit()
95 +
96 +
97 +
98 +
99 + ###############################################################################
100 + # ADD EMAIL TO USER REPOSITORY
101 + ###############################################################################
102 +
103 + if not os.path.isdir(repository_path):
104 + logging.warning('Repository path does not exist: {}'.format(repository_path))
105 + exit()
106 +
107 + try:
108 + repo = pygit2.Repository(repository_path)
109 + except:
110 + logging.warning('Not a valid repository: {}'.format(repository_path))
111 + exit()
112 +
113 + try:
114 + head_tree = repo.revparse_single('HEAD').tree
115 + except:
116 + logging.warning('Could not find HEAD ref: {}'.format(repository_path))
117 + exit()
118 +
119 + try:
120 + configuration = configuration | json.loads(head_tree['configuration'].data.decode('UTF-8'))
121 + except:
122 + logging.info('Could not load configuration file for repository {}. The default configuration will be used instead.'.format(repository_path))
123 +
124 + if configuration['enabled'] == False:
125 + logging.info('Ignoring incoming email for repository {} because emails are disabled.'.format(repository_path))
126 + exit()
127 +
128 + logging.debug('Accepting email from {} to {} with subject {}'.format(email_from, email_to, email_subject))
129 +
130 + # At this point we need to add the incoming email to the repository.
131 + # If the email is a reply (ie. it contains the In-Reply-To header, we retrieve the
132 + # existing tree for the thread. Otherwise, we will create a new tree.
133 +
134 + thread_tree = None
135 + thread_title = '{} {} {}'.format(
136 + datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S'),
137 + email_id_hash,
138 + email_subject.replace('/', '-')
139 + )
140 +
141 + if email_in_reply_to:
142 + # The hash of the email that is being replied to
143 + parent_message_hash = hashlib.sha256(email_in_reply_to.encode('utf-8')).hexdigest()[:8]
144 +
145 + # Find the thread (tree) containing the parent message
146 + for obj in head_tree:
147 + if obj.type_str == 'tree' and parent_message_hash + '.email' in obj:
148 + thread_tree = obj
149 + thread_title = obj.name
150 + break
151 +
152 + # We only accept emails in reply to existing messages
153 + if not thread_tree:
154 + logging.debug('In-Reply-To ID not found in repository: {}'.format(email_in_reply_to))
155 + exit()
156 +
157 + # Add the new email BLOB to the git store
158 + message_oid = repo.create_blob(message_raw)
159 +
160 + # Add the blob that we've just created to the thread tree
161 + thread_tree_builder = repo.TreeBuilder(thread_tree) if thread_tree else repo.TreeBuilder()
162 + thread_tree_builder.insert(email_id_hash + '.email', message_oid, pygit2.GIT_FILEMODE_BLOB)
163 + thread_tree_oid = thread_tree_builder.write()
164 +
165 + # Add the thread tree to the HEAD tree
166 + head_tree_builder = repo.TreeBuilder(head_tree)
167 + head_tree_builder.insert(thread_title, thread_tree_oid, pygit2.GIT_FILEMODE_TREE)
168 + head_tree_oid = head_tree_builder.write()
169 +
170 + repo.create_commit(
171 + repo.head.name, # reference name
172 + pygit2.Signature('CLIF', '-'), # author
173 + pygit2.Signature('CLIF', '-'), # committer
174 + 'New email.', # message
175 + head_tree_oid, # tree of this commit
176 + [ repo.head.target ] # parents commit
177 + )
178 +
179 +
180 +
181 +
182 + ###############################################################################
183 + # FORWARD EMAIL TO THREAD PARTICIPANTS AND TO LIST SUBSCRIBERS
184 + ###############################################################################
185 +
186 + # Remove duplicates, if any
187 + participants = list(set(configuration['subscribers']))
188 +
189 + # Find all the participants in the thread, ie. everyone that has sent an email
190 + thread_tree = repo.get(thread_tree_oid)
191 + for obj in thread_tree:
192 + try:
193 + obj_message = email.message_from_string(obj.data.decode('UTF-8'), policy=email.policy.default)
194 + obj_email_from = email.utils.parseaddr(obj_message.get('from'))[1]
195 +
196 + if obj_email_from not in participants:
197 + participants.append(obj_email_from)
198 + except:
199 + logging.warning('Could not parse file for searching participants: {}'.format(obj.name))
200 +
201 + # Remove list address from participants in order to avoid forwarding to us
202 + while email_to[1] in participants:
203 + participants.remove(email_to[1])
204 +
205 + # Modify some headers before forwarding.
206 + # Note: we need to delete them first because the SMTP client will only accept one
207 + # of them at most, but message[] is an append operator ("message" is an instance of
208 + # email.message.EmailMessage())
209 + # https://docs.python.org/3/library/email.message.html#email.message.EmailMessage
210 + # TODO Some ISPs add the client IP to the email headers. Should we remove *all*
211 + # unnecessary headers instead?
212 + for header in [ 'Sender', 'Reply-To',
213 + 'List-Id', 'List-Subscribe', 'List-Unsubscribe', 'List-Post' ]:
214 + del message[header]
215 +
216 + message['Sender'] = '{}@{}'.format(repository_name, SERVER_DOMAIN)
217 + message['Reply-To'] = '{}@{}'.format(repository_name, SERVER_DOMAIN)
218 + message['List-Id'] = '<{}@{}>'.format(repository_name, SERVER_DOMAIN)
219 + # message['List-Subscribe'] = '<>'
220 + # message['List-Unsubscribe'] = '<>'
221 + message['List-Post'] = '<{}@{}>'.format(repository_name, SERVER_DOMAIN)
222 +
223 + # Forward email to participants
224 + try:
225 + smtp_client = smtplib.SMTP('localhost')
226 +
227 + # "The from_addr and to_addrs parameters are used to construct the message envelope
228 + # used by the transport agents. sendmail does not modify the message headers in any way."
229 + # - https://docs.python.org/3/library/smtplib.html#smtplib.SMTP.sendmail
230 + smtp_client.sendmail(
231 + '{}@{}'.format(repository_name, SERVER_DOMAIN), # Envelope From
232 + participants, # Envelope To
233 + str(message)) # Message
234 +
235 + logging.debug("Successfully sent emails.")
236 + except Exception as e:
237 + logging.error("Error sending emails.")
238 + logging.error(str(e))

+8/-0 A   requirements.txt
index 0000000..bbee4b3
old size: 0B - new size: 65B
new file mode: -rw-r--r--
@@ -0,0 +1,8 @@
1 + bottle
2 + gunicorn
3 + jinja2
4 + pygit2
5 + pygments
6 + python-magic
7 + pytz
8 + timeago

+285/-0 A   static/css/clif.css
index 0000000..1713087
old size: 0B - new size: 5K
new file mode: -rw-r--r--
@@ -0,0 +1,285 @@
1 + * {
2 + /*
3 + margin: 0;
4 + padding: 0;
5 + */
6 + -moz-box-sizing: border-box;
7 + -webkit-box-sizing: border-box;
8 + box-sizing: border-box;
9 + }
10 +
11 + html, body {
12 + font-family: Sans-Serif;
13 + }
14 +
15 + blockquote {
16 + background-color: #f8f8f8;
17 + border-left: 5px solid #e9e9e9;
18 + font-size: .85em;
19 + margin: 1em 0;
20 + padding: .5em 1em;
21 + }
22 +
23 + blockquote cite {
24 + color: #999;
25 + display: block;
26 + font-size: .8em;
27 + margin-top: 1em;
28 + }
29 +
30 + blockquote cite:before {
31 + content: "\2014 \2009";
32 + }
33 +
34 + p {
35 + margin: 0 0 10px 0;
36 + }
37 +
38 + pre {
39 + font-size: 1rem;
40 + line-height: 1.5rem;
41 + }
42 +
43 + .top_bar {
44 + display: flex;
45 + width: 100%;
46 + }
47 +
48 + .top_bar > .path {
49 + flex-basis: auto;
50 + padding: 0 5rem 0 0;
51 + }
52 +
53 + .top_bar > .context {
54 + }
55 +
56 + .page_content {
57 + margin: 2rem 0 5rem 0;
58 + }
59 +
60 + .repository {
61 + display: flex;
62 + width: 100%;
63 + }
64 +
65 + .repository > .readme {
66 + flex: 70%;
67 + font-family: monospace;
68 + font-size: 1rem;
69 + white-space: pre-wrap;
70 + padding: 0 1rem 0 0;
71 + }
72 +
73 + .repository > .overview {
74 + flex: 30%;
75 + padding: 0 0 0 1rem;
76 + }
77 +
78 + /* The page menu at the top */
79 + ul.menu {
80 + display: inline-block;
81 + list-style: none;
82 + margin: 0;
83 + overflow: hidden;
84 + padding: 0;
85 + vertical-align: middle;
86 + }
87 +
88 + ul.menu > a {
89 + border-bottom: .2rem solid transparent;
90 + color: black;
91 + display: inline-block;
92 + float: left;
93 + margin: 0 1rem 0 0;
94 + padding: .1rem 0;
95 + text-decoration: none;
96 + }
97 +
98 + ul.menu > a:hover {
99 + border-bottom: .2rem solid #ccc;
100 + }
101 +
102 + ul.menu > a.selected {
103 + border-bottom: .2rem solid black;
104 + }
105 +
106 + div.ref_title {
107 + font-weight: bold;
108 + margin-bottom: 1rem;
109 + }
110 +
111 + div.ref_title:not(:first-child) {
112 + margin-top: 4rem;
113 + }
114 +
115 + div.ref_title ~ div.ref {
116 + margin: 0 0 0 1rem;
117 + }
118 +
119 + span.head_label {
120 + background-color: #b9faca;
121 + border-radius: .1rem;
122 + border: 1px solid black;
123 + font-size: .6rem;
124 + margin: 0 0 0 1rem;
125 + padding: .1rem;
126 + }
127 +
128 + div.tree_list {
129 + }
130 +
131 + div.tree_list > a {
132 + color: black;
133 + display: block;
134 + margin: 0;
135 + padding: .5rem;
136 + text-decoration: none;
137 + }
138 + div.tree_list > a > pre {
139 + margin: 0;
140 + }
141 +
142 + div.tree_list > a:hover {
143 + background-color: #e3ecfa;
144 + }
145 +
146 + table.log {
147 + border-spacing: 0;
148 + width: 100%;
149 + }
150 +
151 + table.log td {
152 + padding: .2rem .5rem;
153 + white-space: nowrap;
154 + width: auto;
155 + }
156 +
157 + table.log > thead {
158 + font-weight: bold;
159 + }
160 +
161 + table.log > thead td {
162 + padding-bottom: 1rem;
163 + }
164 +
165 + table.log > thead td.time {
166 + text-align: right;
167 + }
168 +
169 + table.log > tbody {
170 + }
171 +
172 + table.log > tbody > tr:hover {
173 + background-color: #e3ecfa;
174 + }
175 +
176 + table.log > tbody td.short_id {
177 + font-family: ui-monospace, monospace;
178 + }
179 +
180 + table.log > tbody td.message {
181 + width: 100%;
182 + }
183 +
184 + table.log > tbody td.message details > .fulltext {
185 + margin: 1rem 2rem;
186 + white-space: pre-wrap;
187 + }
188 +
189 + table.log > tbody td.time {
190 + text-align: right;
191 + }
192 +
193 + div.threads {
194 +
195 + }
196 +
197 + div.threads > div {
198 + margin-bottom: 1rem;
199 + }
200 +
201 + div.threads div.title {
202 + font-weight: bold;
203 + }
204 +
205 + div.threads div.subtitle {
206 + color: #666;
207 + font-size: 0.8rem;
208 + padding-top: .5rem;
209 + }
210 +
211 + .thread {
212 + }
213 +
214 + .thread > .title {
215 + font-size: 1.2rem;
216 + font-weight: bold;
217 + }
218 +
219 + .thread > .subtitle {
220 + color: #666;
221 + margin: 1rem 0 2rem 0;
222 + }
223 +
224 + .thread > .content {
225 + display: flex;
226 + }
227 +
228 + .thread .messages {
229 + flex: 70%;
230 + padding: 1rem;
231 + }
232 +
233 + .thread .message {
234 + border-radius: .1rem;
235 + margin-bottom: 1rem;
236 + }
237 +
238 + .thread .message:not(:last-child) {
239 + border-bottom: 1px solid #d0d0d0;
240 + }
241 +
242 + .thread .message > .header {
243 + font-size: .9rem;
244 + padding: 1rem;
245 + }
246 +
247 + .thread .message > .header > details > .headers {
248 + margin-top: 1rem;
249 + }
250 +
251 + .thread .message > .body {
252 + padding: 1rem;
253 + white-space: pre-wrap;
254 + }
255 +
256 + .thread .info {
257 + flex: 30%;
258 + padding: 1rem;
259 + }
260 +
261 + /* Alternate background color used when displaying table data */
262 + .striped > *:nth-child(even) {
263 + background-color: #f8f8f8;
264 + }
265 +
266 + /* Override some Pygments rules of the default style */
267 + .highlight {
268 + background: none;
269 + }
270 +
271 + .highlight .linenos {
272 + border-right: 1px solid #aaa;
273 + padding-right: .5rem;
274 + }
275 +
276 + .highlight .linenos a,
277 + .highlight .linenos a:hover,
278 + .highlight .linenos a:visited {
279 + color: gray;
280 + text-decoration: none;
281 + }
282 +
283 + .highlight .code {
284 + padding-left: 1rem;
285 + }

+80/-0 A   static/css/pygments_default.css
index 0000000..23b7e0c
old size: 0B - new size: 5K
new file mode: -rw-r--r--
@@ -0,0 +1,80 @@
1 + /**
2 + * CSS styles for highlighting text.
3 + * This is the default color style used by Pygments.
4 + * Generated using:
5 + * pygmentize -S default -f html -a .highlight > pygments_default.css
6 + */
7 +
8 + td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
9 + span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
10 + td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
11 + span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
12 + .highlight .hll { background-color: #ffffcc }
13 + .highlight { background: #f8f8f8; }
14 + .highlight .c { color: #3D7B7B; font-style: italic } /* Comment */
15 + .highlight .err { border: 1px solid #FF0000 } /* Error */
16 + .highlight .k { color: #008000; font-weight: bold } /* Keyword */
17 + .highlight .o { color: #666666 } /* Operator */
18 + .highlight .ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */
19 + .highlight .cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */
20 + .highlight .cp { color: #9C6500 } /* Comment.Preproc */
21 + .highlight .cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */
22 + .highlight .c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */
23 + .highlight .cs { color: #3D7B7B; font-style: italic } /* Comment.Special */
24 + .highlight .gd { color: #A00000 } /* Generic.Deleted */
25 + .highlight .ge { font-style: italic } /* Generic.Emph */
26 + .highlight .gr { color: #E40000 } /* Generic.Error */
27 + .highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */
28 + .highlight .gi { color: #008400 } /* Generic.Inserted */
29 + .highlight .go { color: #717171 } /* Generic.Output */
30 + .highlight .gp { color: #000080; font-weight: bold } /* Generic.Prompt */
31 + .highlight .gs { font-weight: bold } /* Generic.Strong */
32 + .highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
33 + .highlight .gt { color: #0044DD } /* Generic.Traceback */
34 + .highlight .kc { color: #008000; font-weight: bold } /* Keyword.Constant */
35 + .highlight .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
36 + .highlight .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
37 + .highlight .kp { color: #008000 } /* Keyword.Pseudo */
38 + .highlight .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
39 + .highlight .kt { color: #B00040 } /* Keyword.Type */
40 + .highlight .m { color: #666666 } /* Literal.Number */
41 + .highlight .s { color: #BA2121 } /* Literal.String */
42 + .highlight .na { color: #687822 } /* Name.Attribute */
43 + .highlight .nb { color: #008000 } /* Name.Builtin */
44 + .highlight .nc { color: #0000FF; font-weight: bold } /* Name.Class */
45 + .highlight .no { color: #880000 } /* Name.Constant */
46 + .highlight .nd { color: #AA22FF } /* Name.Decorator */
47 + .highlight .ni { color: #717171; font-weight: bold } /* Name.Entity */
48 + .highlight .ne { color: #CB3F38; font-weight: bold } /* Name.Exception */
49 + .highlight .nf { color: #0000FF } /* Name.Function */
50 + .highlight .nl { color: #767600 } /* Name.Label */
51 + .highlight .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
52 + .highlight .nt { color: #008000; font-weight: bold } /* Name.Tag */
53 + .highlight .nv { color: #19177C } /* Name.Variable */
54 + .highlight .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
55 + .highlight .w { color: #bbbbbb } /* Text.Whitespace */
56 + .highlight .mb { color: #666666 } /* Literal.Number.Bin */
57 + .highlight .mf { color: #666666 } /* Literal.Number.Float */
58 + .highlight .mh { color: #666666 } /* Literal.Number.Hex */
59 + .highlight .mi { color: #666666 } /* Literal.Number.Integer */
60 + .highlight .mo { color: #666666 } /* Literal.Number.Oct */
61 + .highlight .sa { color: #BA2121 } /* Literal.String.Affix */
62 + .highlight .sb { color: #BA2121 } /* Literal.String.Backtick */
63 + .highlight .sc { color: #BA2121 } /* Literal.String.Char */
64 + .highlight .dl { color: #BA2121 } /* Literal.String.Delimiter */
65 + .highlight .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
66 + .highlight .s2 { color: #BA2121 } /* Literal.String.Double */
67 + .highlight .se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */
68 + .highlight .sh { color: #BA2121 } /* Literal.String.Heredoc */
69 + .highlight .si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */
70 + .highlight .sx { color: #008000 } /* Literal.String.Other */
71 + .highlight .sr { color: #A45A77 } /* Literal.String.Regex */
72 + .highlight .s1 { color: #BA2121 } /* Literal.String.Single */
73 + .highlight .ss { color: #19177C } /* Literal.String.Symbol */
74 + .highlight .bp { color: #008000 } /* Name.Builtin.Pseudo */
75 + .highlight .fm { color: #0000FF } /* Name.Function.Magic */
76 + .highlight .vc { color: #19177C } /* Name.Variable.Class */
77 + .highlight .vg { color: #19177C } /* Name.Variable.Global */
78 + .highlight .vi { color: #19177C } /* Name.Variable.Instance */
79 + .highlight .vm { color: #19177C } /* Name.Variable.Magic */
80 + .highlight .il { color: #666666 } /* Literal.Number.Integer.Long */

+56/-0 A   templates/about.html
index 0000000..6274389
old size: 0B - new size: 2K
new file mode: -rw-r--r--
@@ -0,0 +1,56 @@
1 + {% extends "index.html" %}
2 +
3 + {% block menu_about_class %}selected{% endblock %}
4 +
5 + {% block content %}
6 +
7 + <p>
8 + <i>Welcome!</i> You have reached a public instance of the <i>CLI Forge</i>.
9 + </p>
10 +
11 + <p>
12 + This is a place for collaborative software development. It offers hosting for
13 + Git repositories and mailing lists, and it aims at being entirely usable from
14 + your CLI by leveraging existing tools and an email-driven workflow.
15 + Read on for a quick introduction to using this instace.
16 + </p>
17 +
18 + <br />
19 +
20 + <p>
21 + <i>New users</i>
22 + <br />&nbsp;&nbsp;&nbsp;&nbsp;
23 + You only need an account if you wish to host your repositories on this instance.
24 + Collaboration is done on mailing lists and you don't need an account for that.
25 + When new users join the instance, they are automatically assigned the namespace
26 + <code>/&lt;username&gt;</code> under which they can add new repositories.<br />
27 + Since this instance is still under testing it is not currently open for public
28 + registrations, but you can still get an account by asking in #peers at irc.libera.chat.
29 + </p>
30 +
31 + <p>
32 + <i>Adding repositories</i>
33 + <br />&nbsp;&nbsp;&nbsp;&nbsp;
34 + If you have an account, you can use CLIF as a remote for sharing your repositories.
35 + Simply running <code>git clone git@{{ domain }}:&lt;namespace&gt;/&lt;repository&gt;</code>
36 + will create a new empty repository that you can add to your list of remotes.
37 + </p>
38 +
39 + <p>
40 + <i>Mailing lists</i>
41 + <br />&nbsp;&nbsp;&nbsp;&nbsp;
42 + Mailing lists are where collaboration happens, and they are stored in repositories
43 + too. All you have to do in order to create a new mailing list is to create
44 + a new repository with the suffix <code>.mlist</code>, for example
45 + <code>git clone git@{{ domain }}:alice/project.mlist</code>. CLIF then will
46 + begin accepting emails for <code>alice/project@{{ domain }}</code> and store
47 + them inside the <code>alice/project.mlist</code> repository.
48 + <br />
49 + New threads are created simply by sending a new email to the list address.
50 + An account is not required.
51 + It is also possible to join an existing thread by sending an email containing
52 + the header <code>In-Reply-To: &lt;Message-ID&gt;</code>, where <code>Message-ID</code>
53 + is the ID value of any previous message.
54 + </p>
55 +
56 + {% endblock %}

+25/-0 A   templates/explore.html
index 0000000..751dd60
old size: 0B - new size: 699B
new file mode: -rw-r--r--
@@ -0,0 +1,25 @@
1 + {% extends "index.html" %}
2 +
3 + {% block menu_explore_class %}selected{% endblock %}
4 +
5 + {% block content %}
6 +
7 + <div>
8 + On this instance:
9 + </div>
10 +
11 + <br />
12 +
13 + {% for repository in repositories %}
14 + <div>
15 + {% if repository.endswith('.mlist.git') %}
16 + <span title="Mailing List">L</span>&nbsp;&nbsp;
17 + <a href="{{ url('threads', repository=repository[:-10]) }}">{{ repository[:-4] }}</a>
18 + {% else %}
19 + <span title="Repository">R</span>&nbsp;&nbsp;
20 + <a href="{{ url('readme', repository=repository[:-4]) }}">{{ repository }}</a>
21 + {% endif %}
22 + </div>
23 + {% endfor %}
24 +
25 + {% endblock %}

+19/-0 A   templates/index.html
index 0000000..3654fed
old size: 0B - new size: 402B
new file mode: -rw-r--r--
@@ -0,0 +1,19 @@
1 + {% extends "layout.html" %}
2 +
3 + {% block path %}
4 + <b>CLIF</b>
5 + {% endblock %}
6 +
7 + {% block context %}
8 + <ul class="menu">
9 + <a
10 + href="{{ url('about') }}"
11 + class="{% block menu_about_class %}{% endblock %}"
12 + >About</a>
13 +
14 + <a
15 + href="{{ url('explore') }}"
16 + class="{% block menu_explore_class %}{% endblock %}"
17 + >Explore</a>
18 + </ul>
19 + {% endblock %}

+21/-0 A   templates/layout.html
index 0000000..93aaf83
old size: 0B - new size: 662B
new file mode: -rw-r--r--
@@ -0,0 +1,21 @@
1 + <!DOCTYPE html>
2 + <html lang="en">
3 + <head>
4 + <meta charset="utf-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1">
6 +
7 + <link href="/static/css/pygments_default.css" rel="stylesheet">
8 + <link href="/static/css/clif.css" rel="stylesheet">
9 +
10 + <title>{{ title if title }}</title>
11 + </head>
12 +
13 + <body>
14 + <div class="top_bar">
15 + <div class="path">{% block path %}{% endblock %}</div>
16 + <div class="context">{% block context %}{% endblock %}</div>
17 + </div>
18 +
19 + <div class="page_content">{% block content %}{% endblock %}</div>
20 + </body>
21 + </html>

+18/-0 A   templates/mailing_list/emails.html
index 0000000..0b55ebf
old size: 0B - new size: 473B
new file mode: -rw-r--r--
@@ -0,0 +1,18 @@
1 + {% extends "mailing_list/mailing_list.html" %}
2 +
3 + {% block content %}
4 +
5 + <div class="threads">
6 + {% for thread in threads %}
7 + <div>
8 + <div class="title">
9 + <a href="{{ url('thread', repository=repository[:-4], thread_id=thread.id) }}">{{ thread.title }}</a>
10 + </div>
11 + <div class="subtitle">
12 + Created {{ thread.datetime|ago }}
13 + </div>
14 + </div>
15 + {% endfor %}
16 + </div>
17 +
18 + {% endblock %}

+54/-0 A   templates/mailing_list/emails_thread.html
index 0000000..4af90c8
old size: 0B - new size: 2K
new file mode: -rw-r--r--
@@ -0,0 +1,54 @@
1 + {% extends "mailing_list/mailing_list.html" %}
2 +
3 + {% block content %}
4 + <div class="thread">
5 +
6 + <div class="title">
7 + {{ thread.title }}
8 + </div>
9 + <div class="subtitle">
10 + #{{ thread.id }} - <span title="{{ thread.datetime }}">Created {{ thread.datetime|ago }}</span>
11 + </div>
12 +
13 + <div class="content">
14 +
15 + <div class="messages">
16 + {% for email in emails %}
17 + <div class="message" id="{{ email.id }}">
18 + <div class="header">
19 + <details>
20 + <summary>
21 + <b>{{ email.from[0] }}</b> &lt;{{ email.from[1] }}&gt;
22 + <span title="Date: {{ email.sent_at }}&#10;Received: {{ email.received_at }}">{{ email.received_at|ago }}</span>
23 + </summary>
24 + <div class="headers">
25 + <div>
26 + <b>Message-ID:</b> <a href="#{{ email.id }}">{{ email.id }}</a>
27 + <div>
28 + {% if email.in_reply_to %}
29 + <div>
30 + <b>In-Reply-To:</b> <a href="#{{ email.in_reply_to }}">{{ email.in_reply_to }}</a>
31 + <div>
32 + {% endif %}
33 + <div><b>Subject:</b> {{ email.subject }}</div>
34 + </div>
35 + </details>
36 + </div>
37 + <div class="body">{{ email.body }}</div>
38 + </div>
39 + {% endfor %}
40 + </div>
41 +
42 + <div class="info">
43 + <details open>
44 + <summary>{{ participants|length }} participants</summary>
45 + {% for address in participants %}
46 + {{ address[0] }} &lt;{{ address[1] }}&gt;
47 + {% endfor %}
48 + </details>
49 + </div>
50 +
51 + </div>
52 + </div>
53 +
54 + {% endblock %}

+11/-0 A   templates/mailing_list/mailing_list.html
index 0000000..d2611f4
old size: 0B - new size: 242B
new file mode: -rw-r--r--
@@ -0,0 +1,11 @@
1 + {% extends "layout.html" %}
2 +
3 + {% block path %}
4 + <a href="/">home</a> /
5 + <a href="{{ url('threads', repository=repository[:-4]) }}">{{ list_address }}</a>
6 + {% endblock %}
7 +
8 + {% block context %}
9 + <ul class="menu">
10 + </ul>
11 + {% endblock %}

+30/-0 A   templates/repository/blob.html
index 0000000..9bf4cc7
old size: 0B - new size: 823B
new file mode: -rw-r--r--
@@ -0,0 +1,30 @@
1 + {% extends "repository/repository.html" %}
2 +
3 + {% block menu_tree_class %}selected{% endblock %}
4 +
5 + {% block content %}
6 +
7 + <div>
8 + <b>ID:</b> {{ blob.id }}
9 + <br />
10 + {% if not blob.is_binary %}
11 + {# 10 is the ASCII integer value of \n
12 + Use 10 instead of \n because blob.data is a byte array, not a string.
13 + #}
14 + {{ blob.data.count(10) }} lines
15 + {% else %}
16 + binary
17 + {% endif %} —
18 + {{ blob.size|human_size(B=True) }} —
19 + <a href="{{ url('raw', repository=repository[:-4], revision=revision, tree_path=tree_path) }}">View raw</a>
20 + </div>
21 +
22 + <br /><br />
23 +
24 + {% if blob.is_binary %}
25 + <i>Cannot display binary object.</i>
26 + {% else %}
27 + {{ blob_formatted|safe }}
28 + {% endif %}
29 +
30 + {% endblock %}

+75/-0 A   templates/repository/log.html
index 0000000..83a0ec6
old size: 0B - new size: 3K
new file mode: -rw-r--r--
@@ -0,0 +1,75 @@
1 + {% extends "repository/repository.html" %}
2 +
3 + {% block menu_log_class %}selected{% endblock %}
4 +
5 + {% block content %}
6 +
7 + <div>
8 + <form action="{{ url('tree_change', repository=repository[:-4]) }}" method="post">
9 + <select name="revision" id="revision">
10 + <optgroup label="heads">
11 + {% for h in heads %}
12 + <option value="{{ h[11:] }}"
13 + {{ "selected" if h[11:] == repository[2] }}
14 + >{{ h[11:] }}{{ ' [HEAD]' if head_ref == h }}</option>
15 + {% endfor %}
16 + </optgroup>
17 +
18 + <optgroup label="tags">
19 + {% for t in tags %}
20 + <option value="{{ t[10:] }}"
21 + {{ "selected" if t[10:] == repository[2] }}
22 + >{{ t[10:] }}</option>
23 + {% endfor %}
24 + </optgroup>
25 + </select>
26 + <input type="submit" value="switch" />
27 + </form>
28 + </div>
29 +
30 + <div class="">
31 +
32 + <table class="log">
33 + <thead>
34 + <tr>
35 + <td class="author">Author</td>
36 + <td class="id">ID</td>
37 + <td class="message">Message</td>
38 + <td class="time">Commit time</td>
39 + </tr>
40 + </thead>
41 +
42 + <tbody class="striped">
43 + {% for commit in commits %}
44 + <tr>
45 + <td title="{{ commit.author.email }}" class="author">
46 + {% if commit.author.name|length > 0 %}
47 + {{ commit.author.name }}
48 + {% else %}
49 + [anonymous]
50 + {% endif %}
51 + </td>
52 + <td class="short_id" title="{{ commit.id }}">
53 + {{ commit.short_id }}
54 + </td>
55 + <td class="message">
56 + {% if commit.message|length <= 100 %}
57 + {{ commit.message }}
58 + {% else %}
59 + <details>
60 + <summary>{{ commit.message[:100] }} ...</summary>
61 + <div class="fulltext">{{ commit.message }}</div>
62 + </details>
63 + {% endif %}
64 + </td>
65 + <td class="time" title="{{ commit_time(commit.commit_time, commit.commit_time_offset) }}">
66 + {{ commit_time(commit.commit_time, commit.commit_time_offset)|ago }}
67 + </td>
68 + </tr>
69 + {% endfor %}
70 + </tbody>
71 + </table>
72 +
73 + </div>
74 +
75 + {% endblock %}

+36/-0 A   templates/repository/readme.html
index 0000000..30a7fdd
old size: 0B - new size: 965B
new file mode: -rw-r--r--
@@ -0,0 +1,36 @@
1 + {% extends "repository/repository.html" %}
2 +
3 + {% block menu_readme_class %}selected{% endblock %}
4 +
5 + {% block content %}
6 +
7 + <div class="repository">
8 +
9 + {% if readme %}
10 + <div class="readme">{{ readme }}</div>
11 + {% else %}
12 + <div class="readme"><i>This repository does not have a README.</i></div>
13 + {% endif %}
14 +
15 + <div class="overview">
16 + <b>Anon. clone</b><br />
17 + https://{{ instance_domain }}/{{ repository[0] }}/{{ repository[1] }}.git
18 +
19 + <br /><br />
20 +
21 + <b>SSH</b><br />
22 + git@{{ instance_domain }}:{{ repository[0] }}/{{ repository[1] }}
23 +
24 + {% if head_ref %}
25 + <br /><br />
26 + <b>HEAD:</b> {{ head_ref }}
27 + {% endif %}
28 +
29 + <br /><br />
30 +
31 + <b>Size:</b> {{ repository_size }}
32 + </div>
33 +
34 + </div>
35 +
36 + {% endblock %}

+27/-0 A   templates/repository/refs.html
index 0000000..0344ec6
old size: 0B - new size: 740B
new file mode: -rw-r--r--
@@ -0,0 +1,27 @@
1 + {% extends "repository/repository.html" %}
2 +
3 + {% block menu_refs_class %}selected{% endblock %}
4 +
5 + {% block content %}
6 +
7 + <div class="ref_title">Heads</div>
8 +
9 + {% for branch in heads %}
10 + <div class="ref">
11 + <a href="{{ url('tree', repository=repository[:-4], revision=branch[11:]) }}">{{ branch[11:] }}</a>
12 +
13 + {% if branch == head %}
14 + <span class="head_label">HEAD</span>
15 + {% endif %}
16 + </div>
17 + {% endfor %}
18 +
19 + <div class="ref_title">Tags</div>
20 +
21 + {% for tag in tags %}
22 + <div class="ref">
23 + <a href="{{ url('tree', repository=repository[:-4], revision=tag[10:]) }}">{{ tag[10:] }}</a>
24 + </div>
25 + {% endfor %}
26 +
27 + {% endblock %}

+30/-0 A   templates/repository/repository.html
index 0000000..79abba3
old size: 0B - new size: 834B
new file mode: -rw-r--r--
@@ -0,0 +1,30 @@
1 + {% extends "layout.html" %}
2 +
3 + {% block path %}
4 + <a href="/">home</a> »
5 + {{ repository }}
6 + {% endblock %}
7 +
8 + {% block context %}
9 + <ul class="menu">
10 + <a
11 + href="{{ url('readme', repository=repository[:-4]) }}"
12 + class="{% block menu_readme_class %}{% endblock %}"
13 + >README</a>
14 +
15 + <a
16 + href="{{ url('refs', repository=repository[:-4]) }}"
17 + class="{% block menu_refs_class %}{% endblock %}"
18 + >Refs</a>
19 +
20 + <a
21 + href="{{ url('tree', repository=repository[:-4], revision='HEAD') }}"
22 + class="{% block menu_tree_class %}{% endblock %}"
23 + >Tree</a>
24 +
25 + <a
26 + href="{{ url('log', repository=repository[:-4], revision='HEAD') }}"
27 + class="{% block menu_log_class %}{% endblock %}"
28 + >Log</a>
29 + </ul>
30 + {% endblock %}

+54/-0 A   templates/repository/tree.html
index 0000000..7679756
old size: 0B - new size: 2K
new file mode: -rw-r--r--
@@ -0,0 +1,54 @@
1 + {% extends "repository/repository.html" %}
2 +
3 + {% block menu_tree_class %}selected{% endblock %}
4 +
5 + {% block content %}
6 +
7 + <div>
8 + <form action="{{ url('tree_change', repository=repository[:-4]) }}" method="post">
9 + <select name="revision" id="revision">
10 + <optgroup label="heads">
11 + {% for h in heads %}
12 + <option value="{{ h[11:] }}"
13 + {{ "selected" if h[11:] == repository[2] }}
14 + >{{ h[11:] }}{{ ' [HEAD]' if head_ref == h }}</option>
15 + {% endfor %}
16 + </optgroup>
17 +
18 + <optgroup label="tags">
19 + {% for t in tags %}
20 + <option value="{{ t[10:] }}"
21 + {{ "selected" if t[10:] == repository[2] }}
22 + >{{ t[10:] }}</option>
23 + {% endfor %}
24 + </optgroup>
25 + </select>
26 + <input type="submit" value="switch" />
27 + </form>
28 + </div>
29 +
30 + <div class="tree_list striped">
31 + {# Display folders (trees) first #}
32 +
33 + {% if tree_path %}
34 + <a href="{{ request.url.rsplit('/', 1)[0] }}">
35 + <pre> 🠕 ..</pre>
36 + </a>
37 + {% endif %}
38 +
39 + {# Display all the trees first #}
40 + {% for obj in tree if obj.type_str == "tree": %}
41 + <a href="{{ request.url }}/{{ obj.name }}">
42 + <pre><span title="{{ obj.id }}">{{ obj.short_id }}</span> {{ obj.filemode|filemode }} <b>🗁</b> {{ obj.name }}</pre>
43 + </a>
44 + {% endfor %}
45 +
46 + {# Display all other files #}
47 + {% for obj in tree if obj.type_str != "tree": %}
48 + <a href="{{ request.url }}/{{ obj.name }}">
49 + <pre><span title="{{ obj.id }}">{{ obj.short_id }}</span> {{ obj.filemode|filemode }} {{ obj.size|human_size }} {{ obj.name }}</pre>
50 + </a>
51 + {% endfor %}
52 + </div>
53 +
54 + {% endblock %}

+670/-0 A   web.py
index 0000000..38edbcf
old size: 0B - new size: 21K
new file mode: -rw-r--r--
@@ -0,0 +1,670 @@
1 + import bottle
2 + from bottle import jinja2_template as template, request, response
3 +
4 + import datetime
5 + import email
6 + import email.policy
7 + import functools
8 + import glob
9 + import hashlib
10 + import magic
11 + import os
12 + import pathlib
13 + import pygit2
14 + import pytz
15 + import re
16 + import stat
17 + import subprocess
18 + import sys
19 + import timeago
20 +
21 + from pygments import highlight
22 + from pygments.lexers import guess_lexer, guess_lexer_for_filename
23 + from pygments.formatters import HtmlFormatter
24 +
25 +
26 +
27 +
28 + ###############################################################################
29 + # SETTINGS
30 + ###############################################################################
31 +
32 + # The root folder where Gitolite stores the repositories. This is used to find the
33 + # actual repositories.
34 + GITOLITE_REPOSITORIES_ROOT = '/home/git/repositories'
35 +
36 + # These are only used when anonymous cloning over HTTPS
37 + GITOLITE_SHELL = '/home/git/bin/gitolite-shell'
38 + GITOLITE_HTTP_HOME = '/home/git'
39 +
40 + # The domain of this instance. This is only really used when displaying list addresses,
41 + # or when the domain needs to be displayed on some pages.
42 + INSTANCE_DOMAIN = 'domain.local'
43 +
44 +
45 +
46 +
47 + ###############################################################################
48 + # UTILITY FUNCTIONS
49 + # This is code that is reused several times within the Bottle controllers
50 + # below, so it's been grouped into functions.
51 + ###############################################################################
52 +
53 + def list_repositories():
54 + repositories = []
55 +
56 + # When topdown is True, the caller can modify the dirnames list in-place and
57 + # walk() will only recurse into the subdirectories whose names remain in dirnames;
58 + # this can be used to prune the search.
59 + # https://docs.python.org/3.12/library/os.html#os.walk
60 + for path, dirs, files in os.walk(GITOLITE_REPOSITORIES_ROOT, topdown=True):
61 + # Remove all files, we only want to recurse into directories
62 + files.clear()
63 +
64 + # This path is a git repo. Remove all sub-dirs because we don't need to
65 + # recurse any further
66 + if path.endswith('.git'):
67 + dirs.clear()
68 +
69 + repository = os.path.relpath(path, GITOLITE_REPOSITORIES_ROOT)
70 +
71 + # DO NOT LIST gitolite-admin repository!
72 + # This is the administration repository of this instance!
73 + if repository.lower() == 'gitolite-admin.git':
74 + continue
75 +
76 + repositories.append(repository)
77 +
78 + repositories.sort()
79 + return repositories
80 +
81 +
82 +
83 +
84 + ###############################################################################
85 + # WEB APP
86 + # Here below are all the Bottle routes and controllers.
87 + ###############################################################################
88 +
89 + if not os.path.isdir(GITOLITE_REPOSITORIES_ROOT):
90 + print('Invalid repositories path: {}'.format(GITOLITE_REPOSITORIES_ROOT))
91 + sys.exit()
92 +
93 + # This only exists for exporting the bottle app object for a WSGI server such as Gunicorn
94 + application = bottle.app()
95 +
96 + # Directories to search for HTML templates
97 + bottle.TEMPLATE_PATH = [ './templates' ]
98 +
99 + def human_size(bytes, B=False):
100 + """
101 + Convert a file size in bytes to a human friendly form.
102 + This is only used in templates when showing file sizes.
103 + """
104 +
105 + for unit in [ 'B' if B else '', 'K', 'M', 'G', 'T', 'P' ]:
106 + if bytes < 1024: break
107 + bytes = bytes / 1024
108 +
109 + return '{}{}'.format(round(bytes), unit).rjust(5)
110 +
111 + def humanct(commit_time, commit_time_offset = 0):
112 + """
113 + The following will add custom functions to the jinja2 template engine.
114 + These will be available to use within templates.
115 + """
116 +
117 + delta = datetime.timedelta(minutes=commit_time_offset)
118 + tz = datetime.timezone(delta)
119 +
120 + dt = datetime.datetime.fromtimestamp(commit_time, tz)
121 +
122 + return dt.astimezone(pytz.utc).strftime('%Y-%m-%d %H:%M:%S')
123 +
124 + template = functools.partial(template, template_settings = {
125 + 'filters': {
126 + 'ago': timeago.format,
127 + 'datetime': lambda date: dateutil.parser.parse(date).strftime('%b %-d, %Y - %H:%M%z%Z'),
128 + # Convert a file's mode to a string of the form '-rwxrwxrwx'
129 + 'filemode': stat.filemode,
130 + # Human-friendly file size:
131 + 'human_size': human_size,
132 + },
133 + 'globals': {
134 + 'commit_time': humanct,
135 + 'instance_domain': INSTANCE_DOMAIN,
136 + 'now': lambda: datetime.datetime.now(datetime.timezone.utc),
137 + 'request': request,
138 + 'url': application.get_url,
139 + },
140 + 'autoescape': True
141 + })
142 +
143 + @bottle.error(404)
144 + def error404(error):
145 + """
146 + Custom 404 page.
147 +
148 + :param error: bottle.HTTPError given by Bottle when calling abort(404).
149 + """
150 +
151 + return '[404] {}'.format(error.body)
152 +
153 + @bottle.get('/static/<filename:path>', name='static')
154 + def static(filename):
155 + """
156 + Path for serving static files.
157 + """
158 +
159 + return bottle.static_file(filename, root='./static/')
160 +
161 + @bottle.get('/', name='about')
162 + def about():
163 + """
164 + The home page displayed at https://domain/
165 + """
166 +
167 + return template('about.html', domain=INSTANCE_DOMAIN)
168 +
169 + @bottle.get('/explore', name='explore')
170 + def explore():
171 + """
172 + The home page displayed at https://domain/
173 + """
174 +
175 + repositories = list_repositories()
176 +
177 + return template('explore.html', repositories=repositories)
178 +
179 + @bottle.get('/<repository:path>.git', name='readme')
180 + def readme(repository):
181 + """
182 + Show README of the repository.
183 +
184 + :param repository: Match repository name ending with ".git"
185 + """
186 +
187 + repository += '.git'
188 + path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository)
189 +
190 + if not os.path.isdir(path):
191 + bottle.abort(404, 'No repository at this path.')
192 +
193 + repo = pygit2.Repository(path)
194 + local_branches = list(repo.branches.local)
195 +
196 + HEAD = None
197 + ref_name = None
198 +
199 + try:
200 + HEAD = repo.head.name
201 + ref_name = HEAD
202 + except:
203 + for name_candidate in [ 'master', 'main', 'trunk', 'development', 'dev' ]:
204 + if name_candidate in local_branches:
205 + ref_name = name_candidate
206 + break
207 +
208 + readme = ''
209 +
210 + if ref_name:
211 + tree = repo.revparse_single(ref_name).tree
212 +
213 + for e in tree:
214 + if e.name.lower() not in [ 'readme', 'readme.md', 'readme.rst' ]:
215 + continue
216 +
217 + if e.is_binary:
218 + continue
219 +
220 + # Read the README content, cut at 1MB
221 + readme = tree[e.name].data[:1048576].decode('UTF-8')
222 + break
223 +
224 + repo_size = sum(f.stat().st_size for f in pathlib.Path(path).glob("**/*"))
225 +
226 + return template('repository/readme.html',
227 + readme=readme,
228 + repository=repository,
229 + repository_size=human_size(repo_size),
230 + head_ref=HEAD)
231 +
232 + @bottle.get('/<repository:path>.git/refs', name='refs')
233 + def refs(repository):
234 + """
235 + List repository refs
236 + """
237 +
238 + repository += '.git'
239 + path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository)
240 +
241 + if not os.path.isdir(path):
242 + bottle.abort(404, 'No repository at this path.')
243 +
244 + repo = pygit2.Repository(path)
245 +
246 + if repo.is_empty:
247 + return template('repository/refs.html',
248 + repository=repository)
249 +
250 + HEAD = None
251 + heads = []
252 + tags = []
253 +
254 + for ref in repo.references:
255 + if ref.startswith('refs/heads/'):
256 + heads.append(ref)
257 + if ref.startswith('refs/tags/'):
258 + tags.append(ref)
259 +
260 + heads.sort()
261 + tags.sort()
262 +
263 + try:
264 + HEAD = repo.head.name
265 + except:
266 + pass
267 +
268 + return template('repository/refs.html',
269 + repository=repository,
270 + heads=heads, tags=tags, head=HEAD)
271 +
272 + @bottle.get('/<repository:path>.git/tree/<revision>', name='tree')
273 + @bottle.get('/<repository:path>.git/tree/<revision>/<tree_path:path>', name='tree_path')
274 + def tree(repository, revision, tree_path=None):
275 + """
276 + Show commit tree.
277 + """
278 +
279 + repository += '.git'
280 + repository_path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository)
281 +
282 + if not os.path.isdir(repository_path):
283 + bottle.abort(404, 'No repository at this path.')
284 +
285 + repo = pygit2.Repository(repository_path)
286 +
287 + if repo.is_empty:
288 + return template('repository/tree.html',
289 + repository=repository, revision=revision)
290 +
291 + git_object = None
292 +
293 + try:
294 + git_object = repo.revparse_single(revision)
295 + except:
296 + pass
297 +
298 + if not git_object:
299 + return template('repository/tree.html',
300 + repository=repository, revision=revision)
301 +
302 + # List all the references.
303 + # This is used for allowing the user to switch revision with a selector.
304 + HEAD = None
305 + heads = []
306 + tags = []
307 + for ref in repo.references:
308 + if ref.startswith('refs/heads/'): heads.append(ref)
309 + if ref.startswith('refs/tags/'): tags.append(ref)
310 + heads.sort()
311 + tags.sort()
312 +
313 + try:
314 + HEAD = repo.head.name
315 + except:
316 + pass
317 +
318 + if git_object.type == pygit2.GIT_OBJ_TAG:
319 + git_object = git_object.peel(None)
320 +
321 + if git_object.type == pygit2.GIT_OBJ_COMMIT:
322 + git_object = git_object.tree
323 +
324 + if git_object.type == pygit2.GIT_OBJ_TREE and tree_path:
325 + git_object = git_object[tree_path]
326 +
327 + if git_object.type == pygit2.GIT_OBJ_TREE:
328 + return template(
329 + 'repository/tree.html',
330 + heads=heads, head_ref=HEAD, tags=tags,
331 + tree=git_object,
332 + tree_path=tree_path,
333 + repository=repository, revision=revision)
334 +
335 + if git_object.type == pygit2.GIT_OBJ_BLOB:
336 +
337 + # Highlight blob text
338 + if git_object.is_binary:
339 + blob_formatted = ''
340 + else:
341 + blob_data = git_object.data.decode('UTF-8')
342 +
343 + # Guess Pygments lexer by filename, or by content if we can't find one
344 + try:
345 + pygments_lexer = guess_lexer_for_filename(git_object.name, blob_data)
346 + except:
347 + pygments_lexer = guess_lexer(blob_data)
348 +
349 + pygments_formatter = HtmlFormatter(nobackground=True, linenos=True, anchorlinenos=True,
350 + lineanchors='line')
351 +
352 + blob_formatted = highlight(blob_data, pygments_lexer, pygments_formatter)
353 +
354 + return template(
355 + 'repository/blob.html',
356 + heads=heads, tags=tags,
357 + blob=git_object,
358 + blob_formatted=blob_formatted,
359 + repository=repository, revision=revision,
360 + tree_path=tree_path)
361 +
362 + bottle.abort(404)
363 +
364 + @bottle.post('/<repository:path>.git/tree', name='tree_change')
365 + def tree_change(repository):
366 + """
367 + Switch revision in tree page.
368 + This route is used by the <form> in the "tree page when changing the revision
369 + to be displayed.
370 + """
371 +
372 + revision = request.forms.get('revision')
373 +
374 + bottle.redirect(application.get_url('tree',
375 + repository=repository,
376 + revision=revision))
377 +
378 + @bottle.get('/<repository:path>.git/log/<revision>', name='log')
379 + def log(repository, revision):
380 + """
381 + Show commit log.
382 + """
383 +
384 + repository += '.git'
385 + repository_path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository)
386 +
387 + if not os.path.isdir(repository_path):
388 + bottle.abort(404, 'No repository at this path.')
389 +
390 + repo = pygit2.Repository(repository_path)
391 +
392 + if repo.is_empty:
393 + return template('repository/log.html',
394 + repository=repository, revision=revision)
395 +
396 + git_object = None
397 +
398 + try:
399 + git_object = repo.revparse_single(revision)
400 + except:
401 + pass
402 +
403 + if not git_object:
404 + return template('repository/log.html',
405 + repository=repository, revision=revision)
406 +
407 + # List all the references.
408 + # This is used for allowing the user to switch revision with a selector.
409 + HEAD = None
410 + heads = []
411 + tags = []
412 + for ref in repo.references:
413 + if ref.startswith('refs/heads/'): heads.append(ref)
414 + if ref.startswith('refs/tags/'): tags.append(ref)
415 + heads.sort()
416 + tags.sort()
417 +
418 + try:
419 + HEAD = repo.head.name
420 + except:
421 + pass
422 +
423 + if git_object.type in [ pygit2.GIT_OBJ_TREE, pygit2.GIT_OBJ_BLOB ]:
424 + return 'Not a valid ref'
425 +
426 + if git_object.type == pygit2.GIT_OBJ_TAG:
427 + git_object = git_object.peel(None)
428 +
429 + # At this point git_object should be a valid pygit2.GIT_OBJ_COMMIT
430 +
431 + # Read 50 commits
432 + commits = []
433 + for commit in repo.walk(git_object.id):
434 + commits.append(commit)
435 + if len(commits) >= 50:
436 + break
437 +
438 + return template(
439 + 'repository/log.html',
440 + heads=heads, head_ref=HEAD, tags=tags,
441 + commits=commits,
442 + repository=repository, revision=revision)
443 +
444 + @bottle.get('/<repository:path>.git/raw/<revision>/<tree_path:path>', name='raw')
445 + def raw(repository, revision, tree_path):
446 + """
447 + Return a raw blow object.
448 + """
449 +
450 + repository += '.git'
451 + repository_path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository)
452 +
453 + if not os.path.isdir(repository_path):
454 + bottle.abort(404, 'No repository at this path.')
455 +
456 + repo = pygit2.Repository(repository_path)
457 +
458 + if repo.is_empty:
459 + return ""
460 +
461 + git_tree = None
462 +
463 + try:
464 + git_object = repo.revparse_single(revision)
465 + except:
466 + pass
467 +
468 + if not git_object or git_object.type != pygit2.GIT_OBJ_COMMIT:
469 + bottle.abort(404, 'Not a valid revision.')
470 +
471 + blob = None
472 +
473 + try:
474 + blob = git_object.tree[tree_path]
475 + except:
476 + bottle.abort(404, 'Object does not exist.')
477 +
478 + if blob.type != pygit2.GIT_OBJ_BLOB:
479 + bottle.abort(404, 'Object is not a blob.')
480 +
481 + mime = magic.from_buffer(blob.data[:1048576], mime=True)
482 + response.content_type = mime
483 + return blob.data
484 +
485 + @bottle.get('/<repository:path>.git/info/refs')
486 + @bottle.post('/<repository:path>.git/git-upload-pack')
487 + def git_smart_http(repository):
488 + """
489 + This controller proxies Git Smart HTTP requests to gitolite-shell for allowing
490 + anonymous clones over HTTP. Looks like anonymous clones are not possible via SSH,
491 + hence why we have this feature.
492 + Note that this controller only matches "git-upload-pack" (used for fetching)
493 + but does not match "git-receive-pack" (used for pushing). Pushing should only
494 + happen via SSH.
495 +
496 + Note: If CLIF is running behind a web server such as httpd or lighttpd, the
497 + same behavior of this controller can be achieved much more simply by configuring
498 + the server with CGI and an alias that redirects the URLs above to the gitolite-shell
499 + script. However, this controller exists so that anonymous HTTP clones can work
500 + "out of the box" without any manual configuration of the server.
501 +
502 + Documentation useful for understanding how this works:
503 + https://git-scm.com/docs/http-protocol
504 + https://bottlepy.org/docs/dev/async.html
505 + https://gitolite.com/gitolite/http.html#allowing-unauthenticated-access
506 + """
507 +
508 + # Environment variables for the Gitolite shell
509 + # TODO Gitolite gives a warning: "WARNING: Use of uninitialized value in concatenation (.) or string at /home/git/bin/gitolite-shell line 239"
510 + # Looks like some non-critical env vars are missing here: REMOTE_PORT SERVER_ADDR SERVER_PORT
511 + gitenv = {
512 + **os.environ,
513 +
514 + # https://git-scm.com/docs/git-http-backend#_environment
515 + 'PATH_INFO': request.path,
516 + 'REMOTE_USER': 'anonymous', # This user must be set in ~/.gitolite.rc like this:
517 + # HTTP_ANON_USER => 'anonymous',
518 + 'REMOTE_ADDR': request.remote_addr,
519 + 'CONTENT_TYPE': request.content_type,
520 + 'QUERY_STRING': request.query_string,
521 + 'REQUEST_METHOD': request.method,
522 + 'GIT_PROJECT_ROOT': GITOLITE_REPOSITORIES_ROOT,
523 + 'GIT_HTTP_EXPORT_ALL': 'true',
524 +
525 + # Additional variables required by Gitolite
526 + 'REQUEST_URI': request.fullpath,
527 + 'GITOLITE_HTTP_HOME': GITOLITE_HTTP_HOME,
528 + 'HOME': GITOLITE_HTTP_HOME,
529 + }
530 +
531 + # Start a Gitolite shell.
532 + # Do not replace .Popen() with .run() because it waits for child process to finish before returning.
533 + proc = subprocess.Popen(
534 + [ GITOLITE_SHELL ],
535 + env = gitenv,
536 + stdin = subprocess.PIPE,
537 + stdout = subprocess.PIPE)
538 + # stderr = )
539 +
540 + # Write the whole request body to Gitolite stdin.
541 + # Don't forget to close the pipe or it will hang!
542 + proc.stdin.write(request.body.read())
543 + proc.stdin.close()
544 +
545 + # Now we process the Gitolite response and return it to the client.
546 +
547 + # First we need to scan all the HTTP headers in the response so that we can
548 + # add them to the bottle response...
549 + for line in proc.stdout:
550 + line = line.decode('UTF-8').strip()
551 +
552 + # Empty line means no more headers
553 + if line == '':
554 + break
555 +
556 + header = line.split(':', 1)
557 + response.set_header(header[0].strip(), header[1].strip())
558 +
559 + # ...then we can return the rest of the Gitolite response to the client as we read it
560 + for line in proc.stdout:
561 + yield line
562 +
563 + @bottle.get('/<repository:path>.mlist', name='threads')
564 + def threads(repository):
565 + """
566 + List email threads.
567 +
568 + :param repository: Match repository name NOT ending with ".git"
569 + """
570 +
571 + path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository + '.mlist.git')
572 + list_address = '{}@{}'.format(repository, INSTANCE_DOMAIN)
573 +
574 + if not os.path.isdir(path):
575 + bottle.abort(404, 'No repository at this path.')
576 +
577 + repo = pygit2.Repository(path)
578 + tree = repo.revparse_single('HEAD').tree
579 +
580 + threads_list = []
581 +
582 + for obj in tree:
583 + if obj.type != pygit2.GIT_OBJ_TREE:
584 + continue
585 +
586 + threads_list.append(obj.name)
587 +
588 + threads_list.sort(reverse=True)
589 +
590 + for i in range(0, len(threads_list)):
591 + thread_date, thread_time, thread_id, thread_title = threads_list[i].split(' ', 3)
592 +
593 + threads_list[i] = {
594 + 'datetime': thread_date + ' ' + thread_time,
595 + 'id': thread_id,
596 + 'title': thread_title
597 + }
598 +
599 + return template('mailing_list/emails.html', threads=threads_list,
600 + list_address=list_address,
601 + repository=repository)
602 +
603 + @bottle.get('/<repository:path>.mlist/<thread_id>', name='thread')
604 + def thread(repository, thread_id):
605 + """
606 + Show a single email thread.
607 + """
608 +
609 + path = os.path.join(GITOLITE_REPOSITORIES_ROOT, repository + '.mlist.git')
610 + list_address = '{}@{}'.format(repository, INSTANCE_DOMAIN)
611 +
612 + if not os.path.isdir(path):
613 + bottle.abort(404, 'No repository at this path.')
614 +
615 + repo = pygit2.Repository(path)
616 + head_tree = repo.revparse_single('HEAD').tree
617 + thread_tree = None
618 +
619 + for obj in head_tree:
620 + if obj.type != pygit2.GIT_OBJ_TREE:
621 + continue
622 +
623 + if thread_id in obj.name:
624 + thread_tree = obj
625 + break
626 +
627 + if not thread_tree:
628 + bottle.abort(404, 'Not a valid thread')
629 +
630 + thread_date, thread_time, thread_id, thread_title = thread_tree.name.split(' ', 3)
631 + thread_data = {
632 + 'datetime': thread_date + ' ' + thread_time,
633 + 'id': thread_id,
634 + 'title': thread_title
635 + }
636 +
637 + # Read all the emails in this thread and collect some statistics on the way (for
638 + # displaying purposes only)
639 + emails = []
640 + participants = []
641 +
642 + for obj in thread_tree:
643 + if obj.type != pygit2.GIT_OBJ_BLOB \
644 + or not obj.name.endswith('.email'):
645 + continue
646 +
647 + message = email.message_from_string(obj.data.decode('UTF-8'), policy=email.policy.default)
648 +
649 + email_data = {
650 + 'id': message.get('message-id'),
651 + 'id_hash': hashlib.sha256(message.get('message-id').encode('utf-8')).hexdigest()[:8],
652 + 'from': email.utils.parseaddr(message.get('from')),
653 + 'to': email.utils.parseaddr(message.get('to')),
654 + 'in_reply_to': message.get('in-reply-to'),
655 + 'sent_at': email.utils.parsedate_to_datetime(message.get('date')).astimezone(pytz.utc).strftime('%Y-%m-%d %H:%M:%S'),
656 + 'received_at': email.utils.parsedate_to_datetime(message.get_all('received')[0].rsplit(";")[-1]).astimezone(pytz.utc).strftime('%Y-%m-%d %H:%M:%S'),
657 + 'subject': message.get('subject'),
658 + 'body': message.get_body(('plain',)).get_content()
659 + }
660 +
661 + emails.append(email_data)
662 +
663 + if email_data['from'] not in participants:
664 + participants.append(email_data['from'])
665 +
666 + emails.sort(key = lambda email: email['received_at'])
667 +
668 + return template('mailing_list/emails_thread.html', thread=thread_data, emails=emails,
669 + participants=participants, list_address=list_address,
670 + repository=repository)

+13/-0 A   web.service
index 0000000..78ae620
old size: 0B - new size: 280B
new file mode: -rw-r--r--
@@ -0,0 +1,13 @@
1 + [Unit]
2 + Description=Gunicorn instance to serve CLIF
3 + After=network.target
4 +
5 + [Service]
6 + User=git
7 + Group=git
8 + WorkingDirectory=/home/git/clif
9 + ExecStart=/home/git/clif/venv/bin/gunicorn --workers 4 --bind localhost:5000 web:application
10 + Restart=always
11 +
12 + [Install]
13 + WantedBy=multi-user.target