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) |
-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 |
index 0000000..82adb58 | |||
old size: 0B - new size: 17B | |||
new file mode: -rw-r--r-- |
@@ -0,0 +1,2 @@ | |||
1 | + | __pycache__ | |
2 | + | venv |
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 | + |
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)) |
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 |
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 | + | } |
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 */ |
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 /> | |
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>/<username></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 /> | |
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 }}:<namespace>/<repository></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 /> | |
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: <Message-ID></code>, where <code>Message-ID</code> | |
53 | + | is the ID value of any previous message. | |
54 | + | </p> | |
55 | + | ||
56 | + | {% endblock %} |
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> | |
17 | + | <a href="{{ url('threads', repository=repository[:-10]) }}">{{ repository[:-4] }}</a> | |
18 | + | {% else %} | |
19 | + | <span title="Repository">R</span> | |
20 | + | <a href="{{ url('readme', repository=repository[:-4]) }}">{{ repository }}</a> | |
21 | + | {% endif %} | |
22 | + | </div> | |
23 | + | {% endfor %} | |
24 | + | ||
25 | + | {% endblock %} |
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 %} |
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> |
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 %} |
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> <{{ email.from[1] }}> | |
22 | + | <span title="Date: {{ email.sent_at }} 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] }} <{{ address[1] }}> | |
47 | + | {% endfor %} | |
48 | + | </details> | |
49 | + | </div> | |
50 | + | ||
51 | + | </div> | |
52 | + | </div> | |
53 | + | ||
54 | + | {% endblock %} |
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 %} |
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 %} |
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 %} |
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 %} |
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 %} |
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 %} |
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 %} |
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) |
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 |