├── .gitignore
├── LICENSE
├── README.md
├── bitboard.py
├── bitmessage_gateway.py
├── chan_objects.py
├── config.py
├── requirements.txt
├── static
├── banner.png
├── favicon.ico
├── screenshot.png
└── thread.png
├── templates
├── base.html
├── elements
│ ├── banner.html
│ ├── board
│ │ ├── post.html
│ │ ├── post_op.html
│ │ ├── post_reply.html
│ │ ├── postform.html
│ │ ├── postheader.html
│ │ └── reply_popup.html
│ ├── chans.html
│ ├── csstheme.css
│ ├── footer.html
│ ├── head.html
│ ├── index
│ │ ├── intro.html
│ │ └── start.html
│ ├── join_popup.html
│ ├── status.html
│ ├── themes_popup.html
│ └── title.html
└── pages
│ ├── alert.html
│ ├── board.html
│ ├── index.html
│ └── thread.html
└── themes.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by .ignore support plugin (hsz.mobi)
2 | .DS_Store
3 | .gitignore
4 | .idea/
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Mike Roberts
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # bitboard
2 |
3 | Bitboard is a decentralized anonymous imageboard. It is built on top of Bitmessage's [decentralized mailing lists.](https://bitmessage.org/wiki/Decentralized_Mailing_List)
4 |
5 | To get started, you must download and setup [Bitmessage](https://bitmessage.org) then [enable API access](https://bitmessage.org/wiki/API_Reference#Enable_the_API). Bitmessage must be running in order for Bitboard to work.
6 |
7 | Once you have Bitmessage setup and running, you may download and run bitboard with the following commands.
8 |
9 | git clone https://github.com/michrob/bitboard
10 | cd bitboard/
11 | python2 -m pip install -r requirements.txt
12 | python2 bitboard.py
13 |
14 | bitboard runs on port 8080 by default, so you should see it running when you visit http://localhost:8080 in your browser. For security purposes, you should probably disable javascript.
15 |
16 |
17 | 
18 |
--------------------------------------------------------------------------------
/bitboard.py:
--------------------------------------------------------------------------------
1 | import web
2 | from bitmessage_gateway import gateway_instance as nexus
3 | import config
4 | import math
5 | import themes
6 | import os
7 | import signal
8 |
9 | t_globals = dict(
10 | math=math,
11 | bitmessage=nexus,
12 | datestr=web.datestr,
13 | themes=themes.themes,
14 | config=config)
15 |
16 | urls = (
17 | '/delete/*(.+)', 'Delete',
18 | '/images/*(.+)', 'Images',
19 | '/join/*(.+)', 'Join',
20 | '/board/*(.+)', 'Board',
21 | '/catalog/*(.+)', 'Catalog',
22 | '/thread/*(.+)', 'Thread',
23 | '/*(.+)', 'Index'
24 | )
25 |
26 | render = web.template.render('templates/', cache=config.cache, globals=t_globals)
27 | render._keywords['globals']['render'] = render
28 | render._keywords['globals']['bitmessage'] = nexus
29 | app = web.application(urls, globals())
30 | app.daemon = True
31 | app.internalerror = web.debugerror
32 |
33 |
34 | class Delete:
35 | def __init__(self):
36 | pass
37 |
38 | def GET(self, url):
39 | web_input = web.input(chan=None, threadid=None, messageid=None)
40 |
41 | render._keywords['globals']['model'] = {"current_thread": web_input.threadid,
42 | "current_chan": web_input.chan}
43 |
44 | render._keywords['globals']['model']['status_message'] = None
45 |
46 | if web_input.messageid:
47 | result = nexus.deleteMessage(web_input.chan, web_input.messageid)
48 | render._keywords['globals']['model']['status_message'] = result
49 | render._keywords['globals']['model']['status_title'] = "Deleted Message"
50 |
51 | if web_input.threadid:
52 | result = nexus.deleteThread(web_input.chan, web_input.threadid)
53 | render._keywords['globals']['model']['status_message'] = result
54 | render._keywords['globals']['model']['status_title'] = "Deleted Thread"
55 |
56 | return render.base(render.pages.alert())
57 |
58 |
59 | class Images:
60 | def __init__(self):
61 | pass
62 |
63 | def GET(self, url):
64 | web_input = web.input(image=None)
65 | return nexus.getImage(web_input.image)
66 |
67 |
68 | class Join:
69 | def __init__(self):
70 | pass
71 |
72 | def POST(self, url):
73 | web_input = web.input(chan=None, passphrase=None)
74 |
75 | render._keywords['globals']['model'] = {"current_chan": web_input.chan}
76 | result = nexus.joinChan(web_input.passphrase)
77 |
78 | render._keywords['globals']['model']['status_title'] = "Success"
79 | render._keywords['globals']['model']['status_message'] = result
80 |
81 | return render.base(render.pages.alert())
82 |
83 |
84 | class Board:
85 | def __init__(self):
86 | pass
87 |
88 | def GET(self, url):
89 | web_input = web.input(chan=None, page=1, threadid=None, theme=None)
90 |
91 | if not web_input.chan:
92 | raise web.seeother("/")
93 |
94 | render._keywords['globals']['model'] = {"current_thread": web_input.threadid,
95 | "current_page": web_input.page,
96 | "current_chan": web_input.chan}
97 |
98 | return render.base(render.pages.board())
99 |
100 | def POST(self, url):
101 | web_input = web.input(chan=None, subject="", body="", image=None, theme=None)
102 |
103 | if web_input.theme:
104 | config.theme = web_input.theme
105 | raise web.seeother(web.ctx.query)
106 |
107 | render._keywords['globals']['model'] = {"current_chan": web_input.chan}
108 |
109 | if not web_input.chan:
110 | render._keywords['globals']['model']['status_message'] = "You must post to a valid chan!"
111 | elif len(web_input.subject.strip()) == 0 or len(web_input.body.strip()) == 0:
112 | render._keywords['globals']['model']['status_message'] = "You must include a subject and message."
113 |
114 | if "status_message" in render._keywords['globals']['model']:
115 | render._keywords['globals']['model']['status_title'] = "Uh oh, something went wrong!"
116 | return render.base(render.pages.alert())
117 |
118 | result = nexus.submitPost(web_input.chan, web_input.subject, web_input.body, web_input.image)
119 | render._keywords['globals']['model']['status_title'] = "Success"
120 | render._keywords['globals']['model']['status_message'] = result
121 |
122 | return render.base(render.pages.alert())
123 |
124 |
125 | class Thread:
126 | def __init__(self):
127 | pass
128 |
129 | def GET(self, url):
130 | web_input = web.input(chan=None, threadid=None, theme=None)
131 |
132 | render._keywords['globals']['model'] = {"current_thread": web_input.threadid,
133 | "current_chan": web_input.chan}
134 |
135 | if not web_input.chan:
136 | raise web.seeother("/")
137 |
138 | return render.base(render.pages.thread())
139 |
140 | def POST(self, url):
141 | web_input = web.input(chan=None, subject="", body="", image=None, theme=None)
142 |
143 | if web_input.theme:
144 | config.theme = web_input.theme
145 | raise web.seeother(web.ctx.query)
146 |
147 | render._keywords['globals']['model'] = {"current_thread": web_input.threadid,
148 | "current_chan": web_input.chan}
149 |
150 | if not web_input.chan:
151 | render._keywords['globals']['model']['status_message'] = "You must post to a valid chan!"
152 | elif len(web_input.subject.strip()) == 0 or len(web_input.body.strip()) == 0:
153 | render._keywords['globals']['model']['status_message'] = "You must include a subject and message."
154 |
155 | if "status_message" in render._keywords['globals']['model']:
156 | render._keywords['globals']['model']['status_title'] = "Uh oh, something went wrong!"
157 | return render.base(render.pages.alert())
158 |
159 | result = nexus.submitPost(web_input.chan, web_input.subject, web_input.body, web_input.image)
160 | render._keywords['globals']['model']['status_title'] = "Success"
161 | render._keywords['globals']['model']['status_message'] = result
162 |
163 | return render.base(render.pages.alert())
164 |
165 |
166 | class Index:
167 | def __init__(self):
168 | pass
169 |
170 | def POST(self, url):
171 | web_input = web.input(theme=None)
172 |
173 | if web_input.theme:
174 | config.theme = web_input.theme
175 | raise web.seeother(web.ctx.query)
176 |
177 | def GET(self, url):
178 |
179 | render._keywords['globals']['model'] = {}
180 | status = nexus.getAPIStatus()
181 |
182 | if not status == True:
183 | render._keywords['globals']['model']['status_title'] = "Success"
184 | render._keywords['globals']['model']['status_message'] = status
185 |
186 | return render.base(render.pages.index())
187 |
188 |
189 | def signal_handler(signal, frame):
190 | os._exit(0)
191 |
192 | if __name__ == "__main__":
193 | signal.signal(signal.SIGINT, signal_handler)
194 | app.run()
195 |
--------------------------------------------------------------------------------
/bitmessage_gateway.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import json
3 | import logging as log
4 | import time
5 | import traceback
6 | import xmlrpclib
7 | from threading import Thread
8 |
9 | import config
10 | from chan_objects import ChanBoard
11 | from chan_objects import ChanPost
12 |
13 |
14 | def getBitmessageEndpoint():
15 | username = config.getBMConfig("apiusername")
16 | password = config.getBMConfig("apipassword")
17 | host = config.getBMConfig("apiinterface")
18 | port = config.getBMConfig("apiport")
19 | return "http://"+username+":"+password+"@"+host+":"+port+"/"
20 |
21 |
22 | class BitMessageGateway(Thread):
23 | def __init__(self):
24 | super(BitMessageGateway, self).__init__()
25 | self._postsById = {}
26 | self._boardByChan = {}
27 | self._chanDict = {}
28 | self._refresh = True
29 | self._api = xmlrpclib.ServerProxy(getBitmessageEndpoint())
30 |
31 | def run(self):
32 | while True:
33 | try:
34 | print "Updating bitmessage info."
35 | self.updateChans()
36 | self.updateChanThreads()
37 |
38 | print `len(self._postsById)` + " total messages " + `len(self._chanDict)` + " total chans."
39 |
40 | for i in range(0, config.bm_refresh_interval):
41 | time.sleep(i)
42 | if self._refresh:
43 | self._refresh = False
44 | break
45 | except Exception as e:
46 | log.error("Exception in gateway thread: " + `e`)
47 | time.sleep(config.bm_refresh_interval)
48 |
49 | def getChans(self):
50 | return self._chanDict
51 |
52 | def deleteMessage(self, chan, messageid):
53 | try:
54 | board = self._boardByChan[chan]
55 | post = self._postsById[messageid]
56 | board.deletePost(post)
57 | del self._postsById[messageid]
58 | except Exception as e:
59 | print "Exception deleting post: " + `e`
60 | traceback.print_exc()
61 | return self._api.trashMessage(messageid)
62 |
63 | def deleteThread(self, chan, threadid):
64 | try:
65 | board = self._boardByChan[chan]
66 | thread = board.getThread(threadid)
67 | if thread:
68 | threadposts = thread.getPosts()
69 | for post in threadposts:
70 | self.deleteMessage(chan, post.msgid)
71 | board.deleteThread(threadid)
72 | except Exception as e:
73 | print "Exception deleting thread: " + repr(e)
74 | traceback.print_exc()
75 | return "Thread [" + repr(threadid) + "] deleted."
76 |
77 | def updateChans(self):
78 | chans = {}
79 | try:
80 | straddr = self._api.listAddresses()
81 | addresses = json.loads(straddr)['addresses']
82 | for jaddr in addresses:
83 | if jaddr['chan'] and jaddr['enabled']:
84 | chan_name = jaddr['label']
85 | chans[chan_name] = jaddr['address']
86 | except Exception as e:
87 | log.error("Exception getting channels: " + `e`)
88 | traceback.print_exc()
89 |
90 | self._chanDict = dict(self._chanDict.items() + chans.items())
91 |
92 | def getChanName(self, chan):
93 | for label, addr in self._chanDict.iteritems():
94 | if addr == chan:
95 | return label
96 |
97 | def getImage(self, imageid):
98 | return self._postsById[imageid].image
99 |
100 | def updateChanThreads(self):
101 | strmessages = self._api.getAllInboxMessageIDs()
102 | messages = json.loads(strmessages)['inboxMessageIds']
103 | for message in messages:
104 | messageid = message["msgid"]
105 |
106 | if messageid in self._postsById:
107 | continue
108 |
109 | strmessage = self._api.getInboxMessageByID(messageid)
110 | jsonmessages = json.loads(strmessage)['inboxMessage']
111 |
112 | if len(jsonmessages) <= 0:
113 | continue
114 |
115 | chan = jsonmessages[0]['toAddress']
116 | post = ChanPost(chan, jsonmessages[0])
117 |
118 | if chan not in self._boardByChan:
119 | self._boardByChan[chan] = ChanBoard(chan)
120 |
121 | self._postsById[messageid] = post
122 | chanboard = self._boardByChan[chan]
123 | chanboard.addPost(post)
124 |
125 | def getThreadCount(self, chan):
126 | if chan not in self._boardByChan:
127 | return 0
128 | return self._boardByChan[chan].getThreadCount()
129 |
130 | def getChanThreads(self, chan, page=1):
131 | if chan not in self._boardByChan:
132 | return []
133 | board = self._boardByChan[chan]
134 |
135 | thread_start = int((int(page) - 1) * config.threads_per_page)
136 | thread_end = int(int(page) * config.threads_per_page)
137 |
138 | return board.getThreads(thread_start, thread_end)
139 |
140 | def getChanThread(self, chan, thread_id):
141 | if chan not in self._boardByChan:
142 | return None
143 |
144 | board = self._boardByChan[chan]
145 |
146 | return board.getThread(thread_id)
147 |
148 | def submitPost(self, chan, subject, body, image):
149 | subject = subject.encode('utf-8').strip()
150 | subjectdata = base64.b64encode(subject)
151 |
152 | msgdata = body.encode('utf-8').strip()
153 |
154 | if image:
155 | imagedata = base64.b64encode(image)
156 | msgdata += "\n\n"
157 |
158 | msg = base64.b64encode(msgdata)
159 |
160 | self._refresh = True
161 | return self._api.sendMessage(chan, chan, subjectdata, msg)
162 |
163 | def joinChan(self, passphrase):
164 | self._refresh = True
165 |
166 | try:
167 | result = self._api.createChan(base64.b64encode(passphrase))
168 | except Exception as e:
169 | result = repr(e)
170 |
171 | return result
172 |
173 | def getAPIStatus(self):
174 | try:
175 | result = self._api.add(2, 2)
176 | except Exception as e:
177 | return repr(e)
178 | if result == 4:
179 | return True
180 | return result
181 |
182 | gateway_instance = BitMessageGateway()
183 | gateway_instance.start()
184 |
--------------------------------------------------------------------------------
/chan_objects.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import datetime
3 | import hashlib
4 | import json
5 | import traceback
6 |
7 | import bleach
8 | from sortedcontainers import SortedListWithKey as sortedlist
9 |
10 | import config
11 |
12 | ID_LENGTH = 9
13 |
14 |
15 | def getThreadId(subject):
16 | sha256 = hashlib.sha256()
17 | sha256.update(subject)
18 | threadId = sha256.hexdigest()
19 | return threadId
20 |
21 |
22 | class ChanPost:
23 | def __init__(self, chan, jsonObj):
24 | self.chan = chan
25 | self.subject = bleach.clean(base64.decodestring(jsonObj['subject'])).encode('utf-8').strip()
26 |
27 | if self.subject.startswith("Re: "):
28 | self.subject = self.subject[4:]
29 |
30 | self.threadid = getThreadId(self.subject)
31 |
32 | self.msgid = bleach.clean(jsonObj['msgid'])
33 | self.postid = self.msgid[-ID_LENGTH:].upper()
34 |
35 | self.timestamp = int(jsonObj['receivedTime'])
36 | self.date = datetime.datetime.fromtimestamp(self.timestamp).strftime('%Y/%m/%d(%a)%H:%M:%S')
37 |
38 | self.toaddr = bleach.clean(jsonObj['toAddress'])
39 | self.fromaddr = bleach.clean(jsonObj['fromAddress'])
40 |
41 | self.username = "Anonymous"
42 | if self.toaddr != self.fromaddr:
43 | self.username = self.fromaddr[-ID_LENGTH:]
44 |
45 | self.image = None
46 | self.body = None
47 |
48 | # set of postId's this post references.
49 | self.targetposts = set([])
50 |
51 | message = base64.decodestring(jsonObj['message'])
52 | try:
53 | self.parseJsonBody(message)
54 | except Exception as e:
55 | if self.subject == "test subject":
56 | print "Exception parsing JSON: " + message + " Exception: " + `e`
57 | self.parsePlaintextBody(message)
58 |
59 | self.markup()
60 |
61 | def parseJsonBody(self, msgdata):
62 | try:
63 | message = json.loads(msgdata)
64 | if 'text' in message:
65 | self.body = bleach.clean(message['text'])
66 | if 'image' in message:
67 | self.image = base64.decodestring(message['image'])
68 | except Exception as e:
69 | raise Exception("parseJsonBody failed. " + `e`)
70 |
71 | def parsePlaintextBody(self, msgdata):
72 | if config.bm_integration:
73 | self.body = msgdata.split("\n" + 54 * "-")[0]
74 | if "data:image" in self.body:
75 | try:
76 | self.image = bleach.clean(self.body.split(",")[1])
77 | if "\"" in self.image:
78 | self.image = self.image.split("\"")[0]
79 | self.image = base64.decodestring(self.image)
80 | except Exception as e:
81 | print "Exception decoding image: " + `e`
82 | print self.image
83 | traceback.print_exc()
84 | self.body = self.body.split("
" + lines[line] + ""
115 | elif lines[line].startswith(">"):
116 | lines[line] = "" + lines[line] + ""
117 | self.body = "\n".join(lines)
118 |
119 |
120 | class ChanThread:
121 | def __init__(self, chan, subject):
122 | self.posts = sortedlist(key=lambda post: post.timestamp)
123 | # postid -> set(replies)
124 | self.repliesByPostId = {}
125 | self.subject = subject
126 | self.timestamp = 0
127 | self.threadid = getThreadId(subject)
128 | self.chan = chan
129 |
130 | def getPosts(self):
131 | return self.posts
132 |
133 | def deletePost(self, post):
134 | try:
135 | self.posts.remove(post)
136 | except Exception as e:
137 | print "Exception removing post: " + `e`
138 | traceback.print_exc()
139 |
140 | def addPost(self, post):
141 | self.posts.add(post)
142 | self.updatePostLinks(post)
143 | if post.timestamp > self.timestamp:
144 | self.timestamp = post.timestamp
145 |
146 | def updatePostLinks(self, post):
147 | for postId in post.targetposts:
148 | if postId not in self.repliesByPostId:
149 | self.repliesByPostId[postId] = set([])
150 | self.repliesByPostId[postId].add(post.postid)
151 |
152 | def getPostReplies(self, postid):
153 | if postid not in self.repliesByPostId:
154 | return set([])
155 | return self.repliesByPostId[postid]
156 |
157 |
158 | class ChanBoard:
159 | def __init__(self, chan):
160 | self._threads = sortedlist(key=lambda thread: -thread.timestamp)
161 | self._threadsById = {}
162 | self.chan = chan
163 |
164 | def getThreadCount(self):
165 | return len(self._threadsById)
166 |
167 | def getThreads(self, start_index, end_index):
168 | return self._threads[start_index:end_index]
169 |
170 | def getThread(self, threadid):
171 | if threadid in self._threadsById:
172 | return self._threadsById[threadid]
173 | return None
174 |
175 | def deletePost(self, post):
176 | threadid = getThreadId(post.subject)
177 | thread = self.getThread(threadid)
178 | if thread:
179 | thread.deletePost(post)
180 |
181 | def deleteThread(self, threadid):
182 | if threadid in self._threadsById:
183 | thread = self._threadsById[threadid]
184 | self._threads.remove(thread)
185 |
186 | def addPost(self, post):
187 | threadid = getThreadId(post.subject)
188 | thread = self.getThread(threadid)
189 |
190 | if not thread:
191 | thread = ChanThread(self.chan, post.subject)
192 | self._threadsById[thread.threadid] = thread
193 | else:
194 | # Remove it because we need to
195 | # re-insert it in sorted order.
196 | self._threads.remove(thread)
197 |
198 | # print "Updating thread: " + `thread.subject`
199 |
200 | thread.addPost(post)
201 | self._threads.add(thread)
202 |
--------------------------------------------------------------------------------
/config.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import sys
3 | import os
4 | from os import path, environ
5 | import ConfigParser
6 |
7 | logging.basicConfig(level=logging.DEBUG)
8 |
9 | cache = False
10 |
11 | bm_integration = True
12 |
13 | bm_refresh_interval = 10
14 |
15 | threads_per_page = 15.0
16 |
17 | theme = "Classic"
18 |
19 |
20 | def getConfigFolder():
21 | appfolder = "PyBitmessage"
22 | dataFolder = None
23 | if "BITMESSAGE_HOME" in environ:
24 | dataFolder = environ["BITMESSAGE_HOME"]
25 | if dataFolder[-1] not in [os.path.sep, os.path.altsep]:
26 | dataFolder += os.path.sep
27 | elif sys.platform == 'darwin':
28 | if "HOME" in environ:
29 | dataFolder = path.join(os.environ["HOME"], "Library/Application Support/", appfolder) + '/'
30 | elif 'win32' in sys.platform or 'win64' in sys.platform:
31 | dataFolder = path.join(environ['APPDATA'].decode(sys.getfilesystemencoding(), 'ignore'), appfolder) + path.sep
32 | else:
33 | try:
34 | dataFolder = path.join(environ["XDG_CONFIG_HOME"], appfolder)
35 | except KeyError:
36 | dataFolder = path.join(environ["HOME"], ".config", appfolder)
37 | dataFolder += '/'
38 | return dataFolder
39 |
40 |
41 | cp = ConfigParser.SafeConfigParser()
42 | cp.read(getConfigFolder() + 'keys.dat')
43 |
44 | settings_section = 'bitmessagesettings'
45 |
46 |
47 | def getBMConfig(setting_key):
48 | value = None
49 | try:
50 | value = cp.get(settings_section, setting_key)
51 | except Exception as e:
52 | pass
53 | return value
54 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | CherryPy
2 | bleach
3 | web.py
4 | sortedcontainers
5 |
--------------------------------------------------------------------------------
/static/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/michrob/bitboard/60a8cedf83b2226099ada187fbdd3165c53e2fc5/static/banner.png
--------------------------------------------------------------------------------
/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/michrob/bitboard/60a8cedf83b2226099ada187fbdd3165c53e2fc5/static/favicon.ico
--------------------------------------------------------------------------------
/static/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/michrob/bitboard/60a8cedf83b2226099ada187fbdd3165c53e2fc5/static/screenshot.png
--------------------------------------------------------------------------------
/static/thread.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/michrob/bitboard/60a8cedf83b2226099ada187fbdd3165c53e2fc5/static/thread.png
--------------------------------------------------------------------------------
/templates/base.html:
--------------------------------------------------------------------------------
1 | $def with (bodyview)
2 |
3 |
4 |
5 | $:render.elements.head()
6 |
12 |31 | -------------------------------------------------------------------------------- /templates/elements/board/post_op.html: -------------------------------------------------------------------------------- 1 | $def with (post, thread) 2 | 3 | $if post.image: 4 |13 | $ groups = text.split('\n') 14 | $ part1 = "
30 |
".join(groups[:max_lines]) 15 | 16 | $:part1 17 | 18 | $if len(groups[max_lines:]) > 0 and isboardview: 19 |
20 | 26 | $else: 27 |
28 | $:"
".join(groups[max_lines:]) 29 |
5 |