├── ips.txt ├── bans.txt ├── README.md ├── archive └── .gitkeep ├── delete.txt ├── boards ├── test │ ├── hide.txt │ ├── ihosts.txt │ ├── info.txt │ └── threads.txt └── list.txt ├── static ├── cap │ └── .gitkeep ├── counter.txt ├── 123.png ├── ng.png ├── rss.png ├── beeL.gif ├── beeR.gif ├── bench.png ├── hands.png ├── secure.png ├── favicon.ico ├── quote.js ├── plain.css ├── main.css ├── orange.css ├── blu.css ├── photon.css ├── zenburn.css ├── futaba.css ├── mwn.css ├── dark.css ├── style.js ├── bee.js └── pseud0ch.css ├── _RUN.bat ├── requirements.txt ├── threads ├── local │ ├── 0 │ │ ├── list.txt │ │ ├── head.txt │ │ └── local.txt │ └── list.txt ├── help.txt └── help.html ├── log.txt ├── Multich.sh ├── droid.ttf ├── html ├── 404.html ├── captcha.html ├── captcha-form.html ├── home.html ├── bottom.html ├── rules.html ├── top.html └── about.html ├── .gitignore ├── _dox ├── APACHE.txt ├── NGINX.txt ├── style.txt ├── boards.txt ├── atom.txt ├── FEDPLAN.txt ├── refresh.txt ├── TO-DOS.txt ├── moderation.txt ├── about-jp.txt ├── frontend-backend.txt ├── HELP.txt └── About.html ├── templ ├── post.t ├── thread.t ├── newr.t └── newt.t ├── daemon.py ├── backup.py ├── tripcode.py ├── update.sh ├── pagemaker.py ├── settings.py ├── mod.py ├── app.py ├── utils.py ├── LICENSE ├── admin.py ├── README ├── home.py ├── tags.py ├── whitelist.py ├── writer.py ├── atom.py ├── refresh.py ├── viewer.py └── boards.py /ips.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bans.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | README -------------------------------------------------------------------------------- /archive/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /delete.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /boards/test/hide.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /boards/test/ihosts.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /boards/test/info.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /boards/test/threads.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/cap/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/counter.txt: -------------------------------------------------------------------------------- 1 | 0 2 | -------------------------------------------------------------------------------- /_RUN.bat: -------------------------------------------------------------------------------- 1 | python -m flask run 2 | -------------------------------------------------------------------------------- /boards/list.txt: -------------------------------------------------------------------------------- 1 | test ehDQcFQQf48o 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | captcha 3 | -------------------------------------------------------------------------------- /threads/local/0/list.txt: -------------------------------------------------------------------------------- 1 | local 0 2 | -------------------------------------------------------------------------------- /log.txt: -------------------------------------------------------------------------------- 1 | local 0 0 0.0.0.0 0<>Anonymous<>a 2 | -------------------------------------------------------------------------------- /threads/local/list.txt: -------------------------------------------------------------------------------- 1 | 0 0 1 1 Hello world 2 | -------------------------------------------------------------------------------- /threads/local/0/head.txt: -------------------------------------------------------------------------------- 1 | Hello world 2 | random 3 | -------------------------------------------------------------------------------- /threads/local/0/local.txt: -------------------------------------------------------------------------------- 1 | 0<>Anonymous<>Hello world 2 | -------------------------------------------------------------------------------- /Multich.sh: -------------------------------------------------------------------------------- 1 | export FLASK_APP=app.py 2 | python3 -m flask run 3 | -------------------------------------------------------------------------------- /droid.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/153/multichan/HEAD/droid.ttf -------------------------------------------------------------------------------- /static/123.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/153/multichan/HEAD/static/123.png -------------------------------------------------------------------------------- /static/ng.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/153/multichan/HEAD/static/ng.png -------------------------------------------------------------------------------- /static/rss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/153/multichan/HEAD/static/rss.png -------------------------------------------------------------------------------- /static/beeL.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/153/multichan/HEAD/static/beeL.gif -------------------------------------------------------------------------------- /static/beeR.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/153/multichan/HEAD/static/beeR.gif -------------------------------------------------------------------------------- /static/bench.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/153/multichan/HEAD/static/bench.png -------------------------------------------------------------------------------- /static/hands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/153/multichan/HEAD/static/hands.png -------------------------------------------------------------------------------- /static/secure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/153/multichan/HEAD/static/secure.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/153/multichan/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /html/404.html: -------------------------------------------------------------------------------- 1 |

404 Error

2 | Sorry, page not found. If you were looking for a thread, 3 | it may have been deleted. 4 |

5 | Go back? 6 | -------------------------------------------------------------------------------- /html/captcha.html: -------------------------------------------------------------------------------- 1 |

Human check

2 | As a way of reducing spam on this website, we will require potential 3 | posters to demonstrate that they are humans once per week. 4 |
5 | -------------------------------------------------------------------------------- /static/quote.js: -------------------------------------------------------------------------------- 1 | function quote(postnumber) { 2 | var text = '>>'+postnumber+'\n'; 3 | var textarea = document.getElementById("reply"); 4 | textarea.value += text; 5 | } 6 | -------------------------------------------------------------------------------- /static/plain.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: monospace, monospace; 3 | background-color: #aaa; 4 | } 5 | 6 | .meta { background-color: #222; 7 | color: #ddd; 8 | padding: 4px; 9 | } 10 | 11 | .meta a { color: #0f0} 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | \#*\# 3 | *txt 4 | settings.py 5 | __pycache__/ 6 | *.py[cod] 7 | static/cap/*png 8 | static/feed.atom 9 | settings.py 10 | log.txt 11 | ips.txt 12 | bans.txt 13 | threads/ 14 | threads/* 15 | archive/ 16 | archive/* 17 | -------------------------------------------------------------------------------- /_dox/APACHE.txt: -------------------------------------------------------------------------------- 1 | # for server multich.kuz.lol 2 | # for port 5150 3 | 4 | 5 | ServerName multich.kuz.lol 6 | ProxyPreserveHost On 7 | ProxyPassReverse / http://localhost:5150/ 8 | ProxyPass / http://localhost:5150/ 9 | 10 | 11 | -------------------------------------------------------------------------------- /templ/post.t: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
{0} )) 5 | Name: {2} @ {1} 6 | 7 | {4} 8 | 9 |
10 | 11 |

{3}

12 |
-------------------------------------------------------------------------------- /templ/thread.t: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | 10 |

{0}

11 |
12 | 13 | source: {1} ♦ tags: {2} 14 |

15 | 16 | {3} 17 |

18 | -------------------------------------------------------------------------------- /threads/help.txt: -------------------------------------------------------------------------------- 1 | api/list.txt 2 | api/tags.txt 3 | api/friends.txt 4 | 5 | api/local/list.txt 6 | api/local/tags.txt 7 | api/board/list.txt 8 | api/board/tags.txt 9 | 10 | api/local/0/head.txt 11 | api/local/0/list.txt 12 | api/local/0/local.txt 13 | 14 | api/board/number/head.txt 15 | api/board/number/list.txt 16 | api/board/number/board.txt 17 | -------------------------------------------------------------------------------- /_dox/NGINX.txt: -------------------------------------------------------------------------------- 1 | # for server bbs.4x13.net 2 | # for port 5150 3 | 4 | server { 5 | listen 80; 6 | server_name bbs.4x13.net; 7 | 8 | location / { 9 | proxy_pass http://127.0.0.1:5150; 10 | proxy_set_header Host $host; 11 | proxy_set_header X-Real-IP $remote_addr; 12 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /_dox/style.txt: -------------------------------------------------------------------------------- 1 | ./html/top.html 2 |
3 |
4 | 5 | ./templ/thread.t 6 | <.thread> 7 | <.thread-title> 8 | <.thread-meta> 9 | ./templ/post.t 10 | <.post local/remote> 11 | <.post-meta> 12 | <.msg> 13 | 14 | 15 | 16 | ./templ/newr.t 17 |
18 |
19 |
20 | 21 | <.footer> 22 | 23 | -------------------------------------------------------------------------------- /daemon.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | import refresh 4 | import settings 5 | import mod 6 | 7 | def linker(): 8 | cnt = 0 9 | while True: 10 | refresh.linksites() 11 | try: 12 | mod.main() 13 | except: 14 | pass 15 | cnt += 1 16 | print(cnt) 17 | time.sleep(settings.refreshtime) 18 | 19 | def run(): 20 | d = threading.Thread(target=linker) 21 | d.start() 22 | -------------------------------------------------------------------------------- /_dox/boards.txt: -------------------------------------------------------------------------------- 1 | ./boards/ 2 | ./boards/list.txt 3 | meta h.V5DKiTQWmc 4 | ./boards/meta/ 5 | /meta/info.txt 6 | /meta/mod.txt host thread @ mode 7 | /meta/hide.txt host thread host comment 8 | 9 | /b/meta 4chan-style list of threads 10 | /b/meta/password edit info.txt, threads.txt, hide.txt 11 | /b/meta/list regular multichan list of trhreads 12 | 13 | # 0 - hide thread 14 | # 1 - normal 15 | # 2 - sticky 16 | # 3 - sage 17 | 18 | host thread @ mode 19 | -------------------------------------------------------------------------------- /html/captcha-form.html: -------------------------------------------------------------------------------- 1 |


2 |
4 | 5 | 6 | 7 |
8 | (new) 9 |

10 | 11 |

12 |

13 |

You need to solve the captcha before you can post. 14 |

15 | -------------------------------------------------------------------------------- /backup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import settings as s 4 | 5 | files = ["./settings.py", 6 | "./threads/", "./html/", "./static/", "./boards/", 7 | s.archive, s.wlist, s.log, s.bans, s.delete] 8 | loc = s.backup 9 | 10 | if not os.path.isdir(loc): 11 | os.mkdir(loc) 12 | 13 | def copy(src, dst): 14 | try: 15 | shutil.copytree(src, dst + src) 16 | except: 17 | shutil.copy(src, dst) 18 | 19 | for f in files: 20 | copy(f, loc) 21 | -------------------------------------------------------------------------------- /tripcode.py: -------------------------------------------------------------------------------- 1 | import settings as s 2 | import crypt 3 | import sys 4 | 5 | def mk(pw): 6 | sect = "" 7 | pw = pw[:8] 8 | salt = (pw + "H.")[1:3] 9 | trip = crypt.crypt(pw, salt) 10 | return trip[-10:] 11 | 12 | def sec(pw): 13 | print(pw) 14 | pw = pw[:10] 15 | salt = s.salt 16 | trip = crypt.crypt(pw, salt) 17 | return trip[-12:] 18 | 19 | if __name__ == "__main__": 20 | print(" !" + mk(sys.argv[1])) 21 | print(" !!" + sec(sys.argv[1])) 22 | 23 | -------------------------------------------------------------------------------- /_dox/atom.txt: -------------------------------------------------------------------------------- 1 | Generate an ATOM feed of the known network: 2 | - /atom/global.atom 3 | 4 | Generate an ATOM feed of SITE_NAME (ex: local) 5 | - /atom/local.atom 6 | - /atom/SITE_NAME.atom 7 | 8 | Generate an ATOM feed of TAG_NAME (ex: random) 9 | - /atom/tag/TAG_NAME.atom 10 | 11 | Generate an ATOM FEED of THREAD from SITE (ex: local/0) 12 | - /atom/local/0.atom 13 | - /atom/SITE/THREAD.atom 14 | 15 | Generate an ATOM feed of last 20 comments from local site: 16 | - /atom/log.atom 17 | 18 | -------------------------------------------------------------------------------- /html/home.html: -------------------------------------------------------------------------------- 1 |

{0}

2 |
3 |
4 |

14 | -------------------------------------------------------------------------------- /templ/newr.t: -------------------------------------------------------------------------------- 1 |

2 |

3 | 4 | 5 | 6 | 7 | 12 |
Author: 8 | 9 |     10 | 11 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /templ/newt.t: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 7 | 10 | 15 |
Title: 5 | 6 |
Tag(s): 8 | (Space seperated!) 9 |
Author: 11 | 12 |     13 | 14 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | wget -N https://raw.githubusercontent.com/153/multichan/master/app.py 3 | wget -N https://raw.githubusercontent.com/153/multichan/master/home.py 4 | wget -N https://raw.githubusercontent.com/153/multichan/master/daemon.py 5 | wget -N https://raw.githubusercontent.com/153/multichan/master/atom.py 6 | wget -N https://raw.githubusercontent.com/153/multichan/master/backup.py 7 | wget -N https://raw.githubusercontent.com/153/multichan/master/refresh.py 8 | wget -N https://raw.githubusercontent.com/153/multichan/master/viewer.py 9 | wget -N https://raw.githubusercontent.com/153/multichan/master/writer.py 10 | wget -N https://raw.githubusercontent.com/153/multichan/master/whitelist.py 11 | wget -N https://raw.githubusercontent.com/153/multichan/master/boards.py 12 | -------------------------------------------------------------------------------- /pagemaker.py: -------------------------------------------------------------------------------- 1 | import os 2 | import settings as s 3 | import utils as u 4 | 5 | with open("./html/top.html", "r") as top: 6 | top = top.read().format(s.name) 7 | with open("./html/bottom.html", "r") as bottom: 8 | bottom = bottom.read() 9 | 10 | if not s.boards: 11 | top = top.replace('boards ♣', '') 12 | 13 | def mk(data=""): 14 | page = "" 15 | 16 | page += top 17 | page += data 18 | page += bottom 19 | 20 | return page 21 | 22 | def html(pname): 23 | path = "./html/" + pname + ".html" 24 | with open(path, "r") as path: 25 | path = path.read() 26 | if not s.boards and pname == "home": 27 | path = path.replace('
  • Boards', '') 28 | return path 29 | -------------------------------------------------------------------------------- /static/main.css: -------------------------------------------------------------------------------- 1 | a, .quote { 2 | color: darkmagenta; 3 | } 4 | body { 5 | max-width: 550px; 6 | background-color: #a6ffbb; 7 | } 8 | 9 | .post { 10 | background-color: #6eff90; 11 | padding-bottom: 4px; 12 | border-bottom: 4px; 13 | } 14 | .meta { 15 | border-bottom: 2px solid black; 16 | padding: 4px; 17 | background-color: #ebffef; 18 | } 19 | .msg { 20 | padding-left: 12px; 21 | padding-right: 8px; 22 | text-align: justify; 23 | } 24 | 25 | img { 26 | max-width: 500px; 27 | text-align: center; 28 | } 29 | 30 | table { 31 | border-collapse: collapse; 32 | } 33 | 34 | th, td { 35 | padding: 4px; 36 | border: 1px solid black; 37 | } 38 | th { 39 | text-align: right; 40 | } 41 | -------------------------------------------------------------------------------- /static/orange.css: -------------------------------------------------------------------------------- 1 | a, .quote { 2 | color: #bd0d00; 3 | } 4 | body { 5 | max-width: 550px; 6 | background-color: #ffda7d; 7 | } 8 | 9 | .post { 10 | background-color: #fdff85; 11 | padding-bottom: 4px; 12 | border-bottom: 4px; 13 | } 14 | .meta { 15 | border-bottom: 2px solid black; 16 | padding: 4px; 17 | background-color: #ebffef; 18 | } 19 | .msg { 20 | padding-left: 12px; 21 | padding-right: 8px; 22 | text-align: justify; 23 | } 24 | 25 | img { 26 | max-width: 500px; 27 | text-align: center; 28 | } 29 | 30 | table { 31 | border-collapse: collapse; 32 | } 33 | 34 | th, td { 35 | padding: 4px; 36 | border: 1px solid black; 37 | } 38 | th { 39 | text-align: right; 40 | } 41 | 42 | -------------------------------------------------------------------------------- /static/blu.css: -------------------------------------------------------------------------------- 1 | a, .quote { 2 | color: #001963; 3 | } 4 | body { 5 | max-width: 550px; 6 | background-color: #4ac6ff; 7 | color: #222; 8 | } 9 | 10 | .post { 11 | background-color: #a3e6d9; 12 | padding-bottom: 4px; 13 | border-bottom: 4px; 14 | } 15 | .meta { 16 | border-bottom: 2px solid black; 17 | padding: 4px; 18 | background-color: #ebffef; 19 | } 20 | .msg { 21 | padding-left: 12px; 22 | padding-right: 8px; 23 | text-align: justify; 24 | } 25 | 26 | img { 27 | max-width: 500px; 28 | text-align: center; 29 | } 30 | 31 | table { 32 | border-collapse: collapse; 33 | } 34 | 35 | th, td { 36 | padding: 4px; 37 | border: 1px solid black; 38 | } 39 | th { 40 | text-align: right; 41 | } 42 | -------------------------------------------------------------------------------- /static/photon.css: -------------------------------------------------------------------------------- 1 | a, .quote { 2 | color: darkmagenta; 3 | } 4 | body { 5 | width: 765px; 6 | margin-left: auto; 7 | margin-right: auto; background-color: #eeeeee; 8 | } 9 | 10 | .post { 11 | background-color: #dddddd; 12 | padding-bottom: 4px; 13 | border-bottom: 4px; 14 | } 15 | .meta { 16 | border-bottom: 2px solid black; 17 | padding: 4px; 18 | background-color: #dddfdf; 19 | } 20 | .msg { 21 | padding-left: 12px; 22 | padding-right: 8px; 23 | text-align: justify; 24 | } 25 | 26 | img { 27 | max-width: 500px; 28 | text-align: center; 29 | } 30 | 31 | table { 32 | border-collapse: collapse; 33 | } 34 | 35 | th, td { 36 | padding: 4px; 37 | border: 1px solid black; 38 | } 39 | th { 40 | text-align: right; 41 | } 42 | a { 43 | color: #ff6600; 44 | text-decoration: none; 45 | } 46 | a:hover { 47 | color: #ff6600; 48 | } -------------------------------------------------------------------------------- /_dox/FEDPLAN.txt: -------------------------------------------------------------------------------- 1 | mkthread(board, thread) 2 | mkboard(board) 3 | mktag(board) 4 | mkall() 5 | 6 | Reserved names: 7 | list, head, global, local 8 | 9 | /api/ 10 | friends.txt 11 | List of hosts, followed by primary host. 12 | Threads and replies to threads will be carried over. 13 | list.txt 14 | Index of threads showing host, creation time, 15 | last reply time, local replies, total replies, and 16 | thread title. 17 | tags.txt 18 | Index of allowed tags and threads associated with them. 19 | 20 | /api/host-example/ 21 | list.txt 22 | Index of threads originating on (host-example). 23 | tags.txt 24 | Index of tags and their threads on (host-example). 25 | 26 | /api/host-example/thread-example/ 27 | head.txt 28 | Thread title and tags. 29 | list.txt 30 | List of all replies to thread by host and time. 31 | local.txt 32 | Local host's replies to thread. 33 | host-example.txt 34 | Another host's replies to thread. 35 | -------------------------------------------------------------------------------- /threads/help.html: -------------------------------------------------------------------------------- 1 |
     2 | 
     3 |   
     4 | /api/list.txt                 - global thread index
     5 | /api/tags.txt                 - global tag index
     6 | /api/friends.txt              - remote board names and urls
     7 | 
     8 |   See /api/friends.txt for site-directory index
     9 | /api/local/list.txt           - site "local" thread index
    10 | /api/local/tags.txt           - site "local" tag index
    11 | /api/board/list.txt
    12 | /api/board/tags.txt
    13 | 
    14 |   See /api/board/list.txt for thread-directory index
    15 | /api/local/0/head.txt         - title, tags
    16 | /api/local/0/list.txt         - reply list
    17 | /api/local/0/local.txt        - local's replies
    18 | /api/board/number/head.txt
    19 | /api/board/number/list.txt
    20 | /api/board/number/board.txt
    21 | 
    
    
    --------------------------------------------------------------------------------
    /static/zenburn.css:
    --------------------------------------------------------------------------------
     1 | a, .quote {
     2 |      color: #dcdccc;
     3 |  }
     4 |  body {
     5 |      margin: auto;
     6 |      max-width: 50%;
     7 |      min-width: 500px; 
     8 |      background-color: #3f3f3f;
     9 |      color: #dcdccc;
    10 | 
    11 |  }
    12 | hr {
    13 |     border-bottom: 2px solid #cc9393;
    14 |  }
    15 |  .post {
    16 |      background-color: #3f3f3f;
    17 |      padding-bottom: 4px;
    18 |      border-bottom: 4px;
    19 |  }
    20 |  .meta {
    21 |      border-bottom: 2px solid #cc9393;
    22 |      padding: 4px;
    23 |      background-color: #3f3f3f;
    24 |  }
    25 |  .msg {
    26 |      padding-left: 12px;
    27 |      padding-right: 8px;
    28 |      text-align: left;
    29 |  }
    30 |  
    31 |  img {
    32 |      max-width: 500px;
    33 |      text-align: center;
    34 |  }
    35 | 
    36 |  table {
    37 |      border-collapse: collapse;
    38 |  }
    39 | 
    40 |  th, td {
    41 |      padding: 4px;
    42 |      border: 1px solid #cc9393;     
    43 |  }
    44 |  th {
    45 |      text-align: left;
    46 |  }
    47 |  input {
    48 | 	 background-color: #c0bed1;
    49 | 	 color: #3f3f3f;
    50 |  }
    51 |  textarea {
    52 | 	 background-color: #c0bed1;
    53 | 	 color: #3f3f3f;
    54 |  }
    55 | 
    
    
    --------------------------------------------------------------------------------
    /static/futaba.css:
    --------------------------------------------------------------------------------
     1 | .quote {
     2 |      color: #789922;
     3 |  }
     4 |  body {
     5 |     width: 765px;
     6 |     margin-left: auto;
     7 |     margin-right: auto;
     8 |     background-color: #ffffee;
     9 |     color: #880000;
    10 |  }
    11 | 
    12 |  .post, footer, header, div, td {
    13 |      background-color: #f0e0d6;
    14 |      padding-bottom: 4px;
    15 |      border-bottom: 4px;
    16 |  }
    17 |  .meta {
    18 |      border-bottom: 2px solid black;
    19 |      padding: 4px;
    20 |      background-color: #eeaa88;
    21 | 	 color: #000;
    22 |  }
    23 | 
    24 |  th {
    25 |      background-color: #eeaa88;
    26 |      font-weight: bold;
    27 |  }
    28 | 
    29 |  .msg {
    30 |      padding-left: 12px;
    31 |      padding-right: 8px;
    32 |      text-align: justify;
    33 |  }
    34 |  
    35 |  table {
    36 |      border-collapse: collapse;
    37 |  }
    38 | 
    39 |  th, td {
    40 |      border: 1px solid #880000;     
    41 |      padding: 4px;
    42 | }
    43 | 
    44 |  th {
    45 |      text-align: right;
    46 |  }
    47 |  a { color:  #d24;font-weight bold}
    48 | 
    49 |  .msg  img {
    50 |      border: 1px solid #880000;     
    51 |      max-width: 500px;
    52 |      max-height: 500px;
    53 |      display: block;
    54 |      margin-left: auto;
    55 |      margin-right: auto;
    56 |  }
    57 | 
    
    
    --------------------------------------------------------------------------------
    /html/bottom.html:
    --------------------------------------------------------------------------------
     1 | 

    2 |

    22 | -------------------------------------------------------------------------------- /html/rules.html: -------------------------------------------------------------------------------- 1 |

    2 |

    3 | We have a few simple rules on this board to make this experiment fun. 4 |

    5 | One. 6 | Do not spam, make posts exessively, post hate speech, post hateful content, 7 | or post links to illegal or offensive content. 8 | The reason for this rule is that violations not only make this server 9 | less fun for its members, but because bad posts will also infect other 10 | servers. 11 |

    12 | Two. 13 | Try to partake in thoughtful or interesting discussion. This can be things 14 | like discussion of current events, books, or even things like your random 15 | thoughts. Controversial discussions are allowed as long as you try to be 16 | respectful. 17 |

    18 | Three. 19 | Do not post NSFW image links unless the thread has an NSFW tag. The 20 | future handling of NSFW content will be resolved if it becomes a 21 | problem. Extreme content will be removed at moderator's discretion. 22 |

    23 | Emergency reports can be made in #multich @ freenode / #multich @ libera, 24 | #0chan @ rizon, or #multich:privacytools.io . 25 |

    26 | -------------------------------------------------------------------------------- /_dox/refresh.txt: -------------------------------------------------------------------------------- 1 | ldsing(board, thread, sub) 2 | mkthread(board, thread) 3 | 4 | ldboard(board, write) 5 | mkboard(board) 6 | pullboard(board) 7 | 8 | mkfriends() 9 | mksite() 10 | syncthread(board, thread) 11 | syncboard(board) 12 | 13 | # Class 1 14 | * ldsing(board, thread, sub) 15 | Simply loads a reply list from a single board in the given 16 | thread. 17 | 18 | * mkthread(board, thread) 19 | Creates a new reply timeline for a given thread. 20 | 21 | * pullthread(board, thread, sub) 22 | Pulls latest reply list to a thread from specified remote host. 23 | If sub is False, all remote boards will be checked. 24 | 25 | # Class 2 26 | * ldboard(board) 27 | Composes a new board index, based on reply timelines. 28 | 29 | * mkboard(board) 30 | Writes a new board index, based on reply timelines. 31 | Applies mkthread() to all threads. 32 | 33 | * pullboard(board) 34 | Sync remote board's threads with local copy. 35 | 36 | # Class 3 37 | * mksite() 38 | Compile all board indexes from all boards into a single sitelist. 39 | 40 | * mkfriends() 41 | Make a friendslist. 42 | 43 | * syncthread(board, thread) 44 | Sync replies to thread from all friends. 45 | 46 | * syncboard(board) 47 | Sync all replies to board's threads from all friends. 48 | -------------------------------------------------------------------------------- /_dox/TO-DOS.txt: -------------------------------------------------------------------------------- 1 | ================================ 2 | ================================ 3 | Spam reduction: 4 | -------- 5 | 1. Limit how many tags can be on a new thread 6 | 2. Limit how many boards an IP can claim per day 7 | Log board creation times. 8 | 9 | Federated moderation: 10 | -------- 11 | 1. Share bans and deletes publicly (opt-in) 12 | 2. In admin panel, show suggested bans/deletes 13 | 3. In admin panel, show suggested servers to follow 14 | 4. Specific URL blacklist for image embed 15 | 16 | Boards: 17 | -------- 18 | 1. "Sub"tags feature 19 | 20 | Maintenance: 21 | -------- 22 | 1. Automated renaming of expired/moved host URL 23 | 2. Refactor boards.py / writer.py / viewer.py 24 | 3. Make "host" consistent term for Multichan server 25 | 26 | Markup expansion: 27 | -------- 28 | 1. Youtube/video embed 29 | 2. < backwards quote (8chan style) 30 | 3. [code][/code] tags for pre-formatted text 31 | 4. %%spoilers%% 32 | 33 | Alternate frontend: 34 | -------- 35 | 1. "Chan" style board view 36 | 2. "Tree" style thread view 37 | 38 | Tools: 39 | -------- 40 | 1. Simple multichan server/network scraper 41 | 2. Convert JSON to Multich. 42 | 3. Scrape Kareha, Emanon, Multich, etc to JSON. 43 | 4. Export multichan public data as JSON or tar.gz 44 | -------------------------------------------------------------------------------- /static/mwn.css: -------------------------------------------------------------------------------- 1 | a, .quote { 2 | color: white; 3 | } 4 | body { 5 | max-width: 820px; 6 | margin-left: auto; 7 | margin-right: auto; 8 | background: #495262; 9 | 10 | // background-color: #3f3f3f; 11 | color: white; 12 | 13 | } 14 | hr { 15 | border-bottom: 2px solid white; 16 | } 17 | .post { 18 | background-color: #585d6b; 19 | padding-bottom: 4px; 20 | border-bottom: 4px; 21 | } 22 | .meta { 23 | border-bottom: 2px solid white; 24 | padding: 4px; 25 | background-color: #585d6b; 26 | } 27 | .msg { 28 | padding-left: 12px; 29 | padding-right: 8px; 30 | text-align: justify; 31 | } 32 | 33 | img { 34 | max-width: 500px; 35 | text-align: center; 36 | } 37 | 38 | table { 39 | border-collapse: collapse; 40 | } 41 | 42 | th, td { 43 | // background: #64697b; 44 | // padding: 4px; 45 | border: 0; 46 | } 47 | th { 48 | text-align: left; 49 | padding-right: 40px; 50 | } 51 | input { 52 | background-color: #c0bed1; 53 | color: #585d6b; 54 | } 55 | textarea { 56 | background-color: #c0bed1; 57 | color: #585d6b; 58 | } 59 | 60 | tr:nth-child(odd){ 61 | background: #64697b; 62 | } 63 | tr:nth-child(even){ 64 | background: #585d6b; 65 | 66 | } 67 | -------------------------------------------------------------------------------- /html/top.html: -------------------------------------------------------------------------------- 1 | 2 | {0} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
    20 | home ♣ 21 | discussions ♣ 22 | tags ♣ 23 | boards ♣ 24 | rules ♣ 25 | about 26 |
    27 |


    28 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | name = "multich" 2 | url = "http://localhost" 3 | _port = 5150 4 | 5 | # These are the suggested #tags for new posts 6 | tags = ["random", "nsfw"] 7 | 8 | # Crypt salt -- used for secure tripcode (2 characters) 9 | # Admin password -- SHA256("changeme") - Not used yet 10 | salt = "changeme" 11 | phash = "057ba03d6c44104863dc7361fe4578965d1887360f90a0895882e58a6248fc86" 12 | 13 | archive = "./archive/" # Used for federation 14 | backup = "./bak/" # Used with ./backup.py 15 | wlist = "./ips.txt" 16 | log = "./log.txt" 17 | delete = "./delete.txt" 18 | bans = "./bans.txt" 19 | 20 | # Spam / flood control 21 | _short = 120 # Name, title, tag field length 22 | _long = 10000 # Post message field length 23 | post = 60 # Make posters wait 60 seconds between posts 24 | thread = 60 * 60 # Make posters wait 1 hour between threads 25 | 26 | # Community features: imageboards 27 | boards = False 28 | images = False 29 | ihost = "https://i.imgur.com/" 30 | 31 | # Tor needs to be installed and pip3 install requests_tor package. 32 | # Only used for .onion friends 33 | torport = 9050 34 | 35 | refreshtime = 60*15 # Check friend boards every 15 minutes 36 | friends = { 37 | "0chan": "http://0chan.vip", 38 | "52chan": "http://bbs.4x13.net", 39 | "ripirc": "http://ripirc.org", 40 | "sageru": "http://multichan.sageru.org", 41 | "local": url 42 | } 43 | -------------------------------------------------------------------------------- /static/dark.css: -------------------------------------------------------------------------------- 1 | a, .quote { 2 | } 3 | 4 | body { 5 | background-color: #111; 6 | color: #ddd; 7 | } 8 | 9 | a { 10 | color: #ccc; 11 | font-weight: bold; 12 | } 13 | h1 { 14 | color: #ddd; 15 | } 16 | td, th { 17 | background-color: #999; 18 | color: #000; 19 | padding: 0.4em; 20 | 21 | } 22 | td a { 23 | color: #111; 24 | font-weight: bold; 25 | } 26 | h2 { 27 | text-align: center; 28 | padding: 1em; 29 | background-color: #eee; 30 | color: #111; 31 | } 32 | .thread-meta { 33 | float: right; 34 | display: block; 35 | padding: 1em; 36 | background-color: #666; 37 | border-left: 3px solid black; 38 | color: #000; 39 | } 40 | 41 | .thread-meta a{ 42 | color: #111; 43 | font-weight: bold; 44 | } 45 | .post { 46 | background-color: #eee; 47 | } 48 | .meta { 49 | width: auto; 50 | padding: 1em; 51 | background-color: #666; 52 | padding-top: 1em; 53 | color: #000; 54 | } 55 | .meta a { 56 | color: #111; 57 | font-weight: bold; 58 | } 59 | .msg { 60 | background-color: #1c1c1c; 61 | color: #eee; 62 | padding: 1em; 63 | } 64 | .msg a, b { 65 | color: #888; 66 | } 67 | .reply td { 68 | background-color: #999; 69 | } 70 | 71 | img { 72 | } 73 | 74 | table { 75 | } 76 | 77 | th, td { 78 | } 79 | th { 80 | } 81 | 82 | -------------------------------------------------------------------------------- /_dox/moderation.txt: -------------------------------------------------------------------------------- 1 | Moderation is done primarily via manipulation of text files, currently. 2 | 3 | 1. log.txt 4 | Lists all comments and posts from local board. 5 | (board thread), local reply number, ipaddress, time<>name<>comment 6 | 7 | 2. ips.txt 8 | Lists IP addresses which have seen the CAPTCHA field. 9 | time-captcha-generated, ipaddress, captcha, time-succeeded 10 | Entries with 4 columns can post freely. 11 | IP addresses with 3 columns or not in list can't post. 12 | 13 | 3. delete.txt 14 | Either removes a thread or places "deleted" comment on a comment. 15 | After an entry, you can add a note, prefixed with ";". 16 | If the authoring board is remote, entry will be deleted again after 17 | syncing with remote board, or when running "mod.py". 18 | board number (delete a thread) 19 | board number board number (delete a comment) 20 | board number ; spam (deleted a thread for reason "spam") 21 | 22 | 4. bans.txt 23 | IPs in this newline seperated list will always fail the captcha. 24 | Optionally, a private ban reason can be added after the IP address. 25 | Admin can ban a range of IPs by only entering the first 3 values. 26 | Comments can by added by following the ip address with a " " space. 27 | 1.2.3.4 Ban the IP 1.2.3.4 28 | 1.2.3 Ban all of 1.2.3.0 -- 1.2.3.255 29 | -------------------------------------------------------------------------------- /mod.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tags 4 | import refresh 5 | import settings as s 6 | 7 | def del_comment(host, thread, site, reply): 8 | path = "/".join(["./threads", host, thread, site]) + ".txt" 9 | with open(path, "r") as comments: 10 | comments = comments.read().splitlines() 11 | reply = int(reply) - 1 12 | comments[reply] = comments[reply].split("<>") 13 | comments[reply][1] = "Deleted" 14 | comments[reply][2] = "this comment was deleted" 15 | comments[reply] = "<>".join(comments[reply]) 16 | comments = "\n".join(comments) + "\n" 17 | with open(path, "w") as path: 18 | path.write(comments) 19 | 20 | def del_thread(host, thread): 21 | path = "/".join(["./threads", host, thread]) 22 | hlistp = "/".join(["./threads", host, "list"]) + ".txt" 23 | with open(hlistp, "r") as hlist: 24 | hlist = hlist.read().splitlines() 25 | hlist = [b for b in hlist if b.split(" ")[0] != thread] 26 | hlist = "\n".join(hlist) 27 | with open(hlistp, "w") as hlistp: 28 | hlistp.write(hlist) 29 | shutil.rmtree(path) 30 | refresh.ldhost(board, host) 31 | refresh.mksite() 32 | 33 | def main(): 34 | with open(s.delete, "r") as delete: 35 | delete = delete.read().splitlines() 36 | delete = [d.split(";")[0] if ";" in d else d for d in delete] 37 | delete = [d.strip().split(" ") for d in delete] 38 | for d in delete: 39 | try: 40 | if len(d) == 2: 41 | del_thread(*d) 42 | elif len(d) == 4: 43 | del_comment(*d[:3], int(d[3])) 44 | if d > 1: 45 | print(d) 46 | except: 47 | pass 48 | tags.mksite(1) 49 | 50 | if __name__ == "__main__": 51 | main() 52 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, send_from_directory 2 | 3 | from home import home 4 | from viewer import viewer 5 | from writer import writer 6 | from whitelist import whitelist 7 | from tags import tags 8 | from atom import atom 9 | from boards import boards 10 | #from admin import admin 11 | #from cookies import cook 12 | 13 | import os 14 | import time 15 | import daemon 16 | import refresh 17 | import pagemaker as p 18 | import settings as s 19 | 20 | _port = s._port 21 | app = Flask(__name__, 22 | static_url_path = "", 23 | static_folder = "static",) 24 | app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 25 | 26 | app.register_blueprint(home) 27 | app.register_blueprint(viewer) 28 | app.register_blueprint(writer) 29 | app.register_blueprint(whitelist) 30 | if s.boards == True: 31 | app.register_blueprint(boards) 32 | app.register_blueprint(tags) 33 | app.register_blueprint(atom) 34 | # app.register_blueprint(admin) 35 | # app.register_blueprint(cook) 36 | 37 | if not os.path.isdir("./static/cap/"): 38 | os.mkdir("./static/cap/") 39 | if not os.path.isdir("./archive/"): 40 | os.mkdir("./archive/") 41 | 42 | @app.errorhandler(404) 43 | def not_found(e): 44 | return p.mk(p.html("404")) 45 | 46 | @app.route('/api/') 47 | @app.route('/raw/') 48 | def api_help(): 49 | return base_static("help.html") 50 | 51 | @app.route('/api/') 52 | @app.route('/raw/') 53 | def base_static(filename): 54 | return send_from_directory(app.root_path + '/threads/', filename) 55 | 56 | if __name__ == '__main__': 57 | refresh.main() 58 | daemon.run() 59 | app.run(host="0.0.0.0", port=_port) 60 | print(time.time.now()) 61 | print("!", request) 62 | 63 | app.run() 64 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | import urllib.request 3 | import settings as s 4 | import requests 5 | 6 | tstring = "%Y-%m-%d %H:%M" 7 | bees = "" 8 | 9 | def unix2hum(unix): 10 | unix = int(unix) 11 | return time.strftime(tstring, time.localtime(unix)) 12 | 13 | def lines(filen): 14 | with open(filen, "r") as filen: 15 | filen = filen.read().splitlines() 16 | filen = [x for x in filen if len(x.strip())] 17 | return len(filen) 18 | 19 | def pclean(post): 20 | post = post.split("
    ") 21 | 22 | def wget(url, fn, w=1): 23 | try: 24 | page = urllib.request.urlopen(url) 25 | page = page.read().decode('utf-8') 26 | except: 27 | page = "" 28 | if ".onion" in url: 29 | from requests_tor import RequestsTor 30 | rt = RequestsTor(tor_ports=(s.torport,)) 31 | try: 32 | page = rt.get(url).text 33 | except Exception as e: 34 | print(e) 35 | page = "" 36 | if "" 60 | # inp = inp.replace(img, " ", 1) 61 | inp = "

    ".join([inp, img2]) 62 | 63 | return inp 64 | 65 | #print(otnow, unix2hum(tnow)) 66 | -------------------------------------------------------------------------------- /static/style.js: -------------------------------------------------------------------------------- 1 | function setActiveStyleSheet(title) { 2 | var i, a, main; 3 | for(i=0; (a = document.getElementsByTagName("link")[i]); i++) { 4 | if(a.getAttribute("rel").indexOf("style") != -1 && a.getAttribute("title")) { 5 | a.disabled = true; 6 | if(a.getAttribute("title") == title) a.disabled = false; 7 | } 8 | } 9 | } 10 | 11 | function getActiveStyleSheet() { 12 | var i, a; 13 | for(i=0; (a = document.getElementsByTagName("link")[i]); i++) { 14 | if(a.getAttribute("rel").indexOf("style") != -1 && a.getAttribute("title") && !a.disabled) return a.getAttribute("title"); 15 | } 16 | return null; 17 | } 18 | 19 | function getPreferredStyleSheet() { 20 | var i, a; 21 | for(i=0; (a = document.getElementsByTagName("link")[i]); i++) { 22 | if(a.getAttribute("rel").indexOf("style") != -1 23 | && a.getAttribute("rel").indexOf("alt") == -1 24 | && a.getAttribute("title") 25 | ) return a.getAttribute("title"); 26 | } 27 | return null; 28 | } 29 | 30 | function createCookie(name,value,days) { 31 | if (days) { 32 | var date = new Date(); 33 | date.setTime(date.getTime()+(days*24*60*60*1000)); 34 | var expires = "; expires="+date.toGMTString(); 35 | } 36 | else expires = ""; 37 | document.cookie = name+"="+value+expires+"; path=/"; 38 | } 39 | 40 | function readCookie(name) { 41 | var nameEQ = name + "="; 42 | var ca = document.cookie.split(';'); 43 | for(var i=0;i < ca.length;i++) { 44 | var c = ca[i]; 45 | while (c.charAt(0)==' ') c = c.substring(1,c.length); 46 | if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length); 47 | } 48 | return null; 49 | } 50 | 51 | window.onload = function(e) { 52 | var cookie = readCookie("style"); 53 | var title = cookie ? cookie : getPreferredStyleSheet(); 54 | setActiveStyleSheet(title); 55 | } 56 | 57 | window.onunload = function(e) { 58 | var title = getActiveStyleSheet(); 59 | createCookie("style", title, 365); 60 | } 61 | 62 | var cookie = readCookie("style"); 63 | var title = cookie ? cookie : getPreferredStyleSheet(); 64 | setActiveStyleSheet(title); 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | All original code is public domain; the one file that is not public 2 | domain is the `droid.ttf` file, which is licensed under the Apache 3 | license. 4 | 5 | ---- 6 | This is free and unencumbered software released into the public domain. 7 | 8 | Anyone is free to copy, modify, publish, use, compile, sell, or 9 | distribute this software, either in source code form or as a compiled 10 | binary, for any purpose, commercial or non-commercial, and by any 11 | means. 12 | 13 | In jurisdictions that recognize copyright laws, the author or authors 14 | of this software dedicate any and all copyright interest in the 15 | software to the public domain. We make this dedication for the benefit 16 | of the public at large and to the detriment of our heirs and 17 | successors. We intend this dedication to be an overt act of 18 | relinquishment in perpetuity of all present and future rights to this 19 | software under copyright law. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 22 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 23 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 24 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 25 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 26 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 27 | OTHER DEALINGS IN THE SOFTWARE. 28 | 29 | For more information, please refer to 30 | 31 | 32 | ---- 33 | # droid.ttf copyright notice 34 | 35 | Copyright: 36 | > Droid is a trademark of Google Corp. 37 | 38 | > License: Apache-2.0 39 | 40 | Copyright 2006, 2007, 2008, 2009, 2010 Google Corp. 41 | 42 | Licensed under the Apache License, Version 2.0 (the "License"); 43 | you may not use this file except in compliance with the License. 44 | You may obtain a copy of the License at 45 | 46 | http://www.apache.org/licenses/LICENSE-2.0 47 | 48 | Unless required by applicable law or agreed to in writing, software 49 | distributed under the License is distributed on an "AS IS" BASIS, 50 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 51 | See the License for the specific language governing permissions and 52 | limitations under the License. 53 | -------------------------------------------------------------------------------- /static/bee.js: -------------------------------------------------------------------------------- 1 | // an enormous thank you to kig of fhtr.org for his limitless patience, 2 | // and to sphingonotus for the BEE gif 3 | // HACKED BY CHINESE 4 | var Tension = -4 5 | var right = "/beeR.gif" 6 | var left = "/beeL.gif" 7 | 8 | function randomCoord() { 9 | var x=document.body.scrollLeft||document.documentElement.scrollLeft; 10 | var y=document.body.scrollTop||document.documentElement.scrollTop; 11 | var w=window.innerWidth||document.body.offsetWidth; 12 | var h=window.innerHeight||document.body.offsetHeight; 13 | return {x:x+Math.round(Math.random()*w), 14 | y:y+Math.round(Math.random()*h)}; 15 | } 16 | 17 | function cardinalPoint(u, coords) { 18 | var u2 = u*u 19 | var u3 = u2*u 20 | var s = (1-Tension)/2; 21 | var CAR0 = -s*u3 + 2*s*u2 - s*u 22 | var CAR1 = (2-s)*u3 + (s-3)*u2 + 1 23 | var CAR2 = (s-2)*u3 + (3-2*s)*u2 + s*u 24 | var CAR3 = s*u3 - s*u2 25 | return { 26 | x: Math.floor(coords[0].x * CAR0 + coords[1].x * CAR1 + coords[2].x * CAR2 + coords[3].x * CAR3), 27 | y: Math.floor(coords[0].y * CAR0 + coords[1].y * CAR1 + coords[2].y * CAR2 + coords[3].y * CAR3) 28 | }; 29 | } 30 | 31 | function animate() 32 | { 33 | (new Image).src=left; 34 | (new Image).src=right; 35 | 36 | var bee=document.createElement("img"); 37 | bee.style.zIndex=1000; 38 | bee.style.position="absolute"; 39 | /* for Internet Explorer */ 40 | /*@cc_on @*/ 41 | /*@if (@_win32) 42 | var iekludge=function() { 43 | if(document.readyState=="complete") document.body.appendChild(bee); 44 | else setTimeout(iekludge,100); 45 | } 46 | iekludge(); 47 | @else */ 48 | document.body.appendChild(bee); 49 | /*@end @*/ 50 | 51 | var time=0; 52 | var coords=[randomCoord(),randomCoord(),randomCoord(),randomCoord()]; 53 | 54 | setInterval(function() { 55 | time+=0.03; 56 | if(time>1) { 57 | time-=1; 58 | coords=[coords[1],coords[2],coords[3],randomCoord()]; 59 | } 60 | 61 | var coord=cardinalPoint(time,coords); 62 | 63 | if(coord.x>parseInt(bee.style.left)) { 64 | if(bee.src!=right) bee.src=right; 65 | } else { 66 | if(bee.src!=left) bee.src=left; 67 | } 68 | 69 | bee.style.left=coord.x+"px"; 70 | bee.style.top=coord.y+"px"; 71 | },40) 72 | } 73 | 74 | function tryanimate() 75 | { 76 | if(document.body) animate(); 77 | else setTimeout(tryanimate,100); 78 | } 79 | 80 | tryanimate(); 81 | -------------------------------------------------------------------------------- /_dox/about-jp.txt: -------------------------------------------------------------------------------- 1 | 2 | このサイトについて 3 | 4 | 注意:このプロジェクトは進行中です 5 | Bitbucketで開発をしています。 6 | 7 | multichan、またはmultipleのチャネル[1]は、会話のための新しい分散型プラットフォームです。 8 | 他のソーシャルメディアプラットフォームとは異なり、 9 | a)会話に参加するための登録は必要がありません; 10 | b)会話はシングルエンティティによって"所有"やコントロールされていません; 11 | c)操作制があるアルゴリズムと迷惑な広告はソフトウェアに含まれていません; 12 | 13 | より大きなコミュニティへの参加を希望するユーザーは、分散機能への参加ができるソフトウェアを利用して任意のウェブサイトに接続できます。 14 | 簡単に言えば、会話は議論を続ける手助けをしているすべてのコンピューター間で共有されます。 1つのWebサイトに接続すると、他のWebサイトからの会話を見ることができます。 15 | メッセージをWebサイトに投稿すると、メッセージを他のWebサイトに転送するドミノ倒し効果がトリガーされます。 16 | 17 | この分散化は、チャットソフトウェアの基盤としては珍しい概念に聞こえるかもしれませんが、 18 | しかし、ソフトウェアの作成者は、グループチャットをエレガントな方法で実行することに関連する問題の多くを排除するため、調査する価値があると考えています。 19 | デスクトップコンピュータを持っている人は誰でもネットワークを拡大することができ、検閲をより困難にします。 20 | 21 | ネットワークを分散化すると、プラットフォームを維持するための全体的なコストも削減されます。 22 | 私たちと一緒にネットワークを運営するのを手伝うことを選択した場合、電気代が増えることすら気が付かないでしょう; 23 | サーバー上のコンテンツを好きなように完全に管理することもできます。 24 | ただし、人気のないモデレーションの決定により、ユーザーがサーバーから遠ざかったり、他のサーバーオペレーターに影響を与えてサーバーからの出力を無視したりして、ユーザーが提供するコンテンツのアウトリーチを制限する可能性があることに注意してください。 25 | 26 | フォーマットとイースターエッグ 27 | 28 | Atomフィードを使用すると、デスクトップ、モバイル、チャットルームからMultichanを簡単に利用できます。ウィキペディアでAtomフィードの詳細をご覧ください。 29 | ハッカーは、 public /api/ディレクトリを楽しむことができます。 30 | 新しいディスカッションを開始したり、既存のディスカッションに返信したりする前に、キャプチャを解決する必要があります。 31 | ページ上部の友達リストに "image:"リンクが表示されている場合、投稿内のホストからの最初の画像URLは、投稿の下部に添付ファイルとして表示されます。この疑似アップロードモードは、将来さらに調整される予定です。 32 | 友達リストにBoards Enabledと表示されている場合は、/b/に移動してボードリストを取得し、/b/board/に移動してboard "/board/"を表示し、 /b/board/passwordに移動して"/board/"を要求します。パスワードを入力するか、パスワードを使用して"/board/"の管理パネルにログインします。 (ログインするボードを要求した後、更新する必要がある場合があります 33 | タグは小文字で、文字、数字、または"_"のみにする必要があります 34 | タグリストの上部にある太字のタグは、管理者のお気に入りのタグです。これらは、新しい投稿を何について書くべきかわからない場合に提案されるトピックです。 35 | タグビューは、"+"を使用して組み合わせることができます。例: 36 |  http://0chan.vip/tags/meta+tech/ <-- view "meta" と "tech" 37 | コメントは、番号やリンクをクリックすることで返信できます。ディスカッションを作成したサイトからのコメントの場合、返信は>>1 >>2 >>3 >>4 .のようにフォーマットされます。 38 | 他のサイトからの返信は、そのサイトの返信リストにあるURLと返信番号によって参照されます。理解するのに少し混乱してしまう可能性があるので、コメント番号をクリックするだけで、multichanに理解させることができます。例は次のようになります。>> http://0chan.vip/3 39 | トリップコードは、名前の後に"#"記号を付けてからパスワード(最大8文字)を入力することできます。 40 | (例) name#test -> name !.CzKQna1OU 41 | (例) dev#Multich -> dev !g4hkX2f/2g 42 | 名前の横に色付きのコードが表示されていますか?パスワードは、パブリックメソッドを使用して"ハッシュ"に変換されます。同じトリップコードを使用して投稿できるのは、同じパスワードを知っている人だけです。この形式の疑似登録は、multichanだけでなく、ほとんどのテキストボードや画像掲示板で使用できます。 43 | 44 | タグ 45 | ディスカッションインデックス 46 | スタートディスカッション 47 | 友人のウェブサイト 48 | atomフィールド 49 | -------------------------------------------------------------------------------- /_dox/frontend-backend.txt: -------------------------------------------------------------------------------- 1 | ======================================== 2 | frontend: 3 | ======================================== 4 | / homepage 5 | rules rules/policies 6 | about about page 7 | friends other peers 8 | 9 | /threads 10 | / thread index 11 | /host/ host's thread index 12 | /host/thread/ show thread, reply 13 | /create create a new thread 14 | 15 | /tags 16 | / tag index 17 | /tag/ tag's thread index 18 | /tag+tag2+tag3/ tag OR tag2 OR tag3 19 | /tag^tag2^tag3/ tag AND tag2 AND tag3 20 | /tag-tag2-tag3/ tag NOT tag2 NOT tag3 21 | 22 | /b 23 | / board index 24 | /board/ view board 25 | /board/password board admin panel 26 | /board/host/thread/ view thread 27 | 28 | /atom 29 | / help / index about atom 30 | /global.atom newest threads in known network 31 | /local.atom newest threads on local host 32 | /hostname.atom newest threads on hostname 33 | /tag/tagname.atom newest threads in #tagname 34 | /host/thread.atom newest posts in host/thread 35 | 36 | ======================================== 37 | backend: 38 | ======================================== 39 | settings.py url, title, friends, refresh rate, post rate, input length 40 | log.txt host thread reply# ip time<>name<>comment 41 | delete.txt host thread ; comment 42 | ban.txt ip comment 43 | ips.txt time ip captcha solved? 44 | 45 | threads/ 46 | threads/friends.txt name url 47 | threads/tags.txt tag host-thread host-thread ... 48 | threads/list.txt host created lastreply localrep# globalrep# title 49 | 50 | threads/host/tags.txt tag thread thread ... 51 | threads/host/list.txt created lastreply localrep# globalrep# title 52 | 53 | threads/host/thread/head.txt title\ntag1 tag2 54 | threads/host/thread/list.txt host replytime 55 | threads/host/thread/host.txt time<>name<>comment 56 | 57 | boards/ 58 | list.txt name securetripcode 59 | board/info.txt markup-supported pad for mods to edit 60 | board/ihosts.txt url 61 | board/hide.txt host time host number 62 | board/threads.txt host time @ mode 63 | 64 | html/ 65 | top.html 66 | bottom.html 67 | home.html 68 | 404.html 69 | rules.html 70 | about.html 71 | captcha.html 72 | captcha-form.html 73 | 74 | -------------------------------------------------------------------------------- /admin.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha256 2 | from flask import Blueprint, request 3 | import settings as s 4 | import pagemaker as p 5 | 6 | admin = Blueprint("admin", __name__) 7 | 8 | def hash(pw=''): 9 | hash = sha256() 10 | hash.update(pw) 11 | return str(hash.hexdigest()) 12 | 13 | @admin.route('/admin/') 14 | def front(): 15 | return "password needed" 16 | 17 | @admin.route('/admin/') 18 | def login(pw): 19 | page = "" 20 | hpw = bytes(pw, "utf-8") 21 | hashed = hash(hpw) 22 | if hashed != s.phash: 23 | return "password?" 24 | cook = f"""\n""" 25 | page += "

    "
    26 |     page += """* log
    27 | * ips
    28 | * delete
    29 | * bans
    30 | * friends
    31 | * tags
    32 | * threads
    33 | * an example thread
    34 | """
    35 |     # Show the comment / thread log
    36 |     page += "

    #log

    " 37 | page += "site | thread | reply | ip\n" 38 | with open("../0chan.vip/log.txt", "r") as log: 39 | log = log.read().splitlines()[::-1] 40 | for n, x in enumerate(log): 41 | x = x.split(" ") 42 | x[-1] = ".".join(x[-1].split(".")[:2]) 43 | log[n] = " ".join(x) 44 | page += "\n".join(log) 45 | 46 | # Show the trash posts 47 | page += "

    #delete

    "
    48 |     with open("../0chan.vip/delete.txt", "r") as delete:
    49 |         delete = delete.read().splitlines()[::-1]
    50 |         page += "\n".join(delete)
    51 | 
    52 |     page += "

    #ips

    "
    53 |     with open("../0chan.vip/ips.txt", "r") as ips:
    54 |         ips = ips.read()
    55 |     page += "time     | ip       | captcha | approved time\n"
    56 |     page += ips
    57 |     # Show the bans
    58 |     page += "

    #bans

    "
    59 |     with open("../0chan.vip/bans.txt", "r") as bans:
    60 |         bans = bans.read()
    61 |     page += bans
    62 |     
    63 |     # Show friends
    64 |     page += "

    #friends

    "
    65 |     with open("../0chan.vip/threads/friends.txt", "r") as friends:
    66 |         friends = friends.read()
    67 |     page += friends
    68 |     
    69 |     page += "
    " 70 | return p.mk(page) 71 | 72 | 73 | 74 | if __name__ == "__main__": 75 | print(hash(s.password)) 76 | -------------------------------------------------------------------------------- /static/pseud0ch.css: -------------------------------------------------------------------------------- 1 | .quote { 2 | color: #789922; 3 | } 4 | 5 | body { 6 | background-color: #C5AD99; 7 | font-family: Mona, 'MS PGothic' !important; 8 | word-wrap: break-word; 9 | line-height: 1; 10 | font-size: 12pt; 11 | background-image: url(data:image/gif;base64,R0lGODdhPAA8AJkAANCznMWtmbOektzApiwAAAAAPAA8AAAC/5SPqcvtCJ6c1IUAsgZ48wuG4qV1njYMZxmkbjYGQpipJeDmuo7zdYoTRSwiRGuFg3lio4HpswF9SJzhobpMSXsZAVQoS6KcpoH3MsPIZsAtVWAbDoVYziuZ3nhhnavWVASVFkYVNdbixKbH0mXQQ8I0FdERVnOi4tXWcqFVZXKAqAbJNEoTdUT28vSlZvUoFRmpxIdh87LXw1Z4cAc5wzFIulIbt+FS6MELVBJsMPK7utqTAmfrbAPoqAlCVknaOkWsclej7VzlRyYJAhc+slTllDNFbZBIq+10I3WGJks77cc4bCSgAcsUp1SESd9+hRv4Q9wiT/aWwWIHMEQaXP/GyNn52EEfjIrYoGGkpVHaGEuF2kTZiFASvm8qp+2YR4xlophqHEKpcwlFOWjO0IB6cGRQMBlMm86oAFXBnzVRHcwbs2xFDkQ8lt28aSzMPHWFCHHNKkaDo44etk5LM7DerFJ/6hSTM6xSWrUkgGDieOIIl46IgNJxl7JwrnY34tzViuQiw2c1+k662wmoVhqC3yD29YOPLnhIbqBE9hOl0tBmLht6VDqy5NPPOKmQYabFRko+I7OAtEQYoTZeTAaLPQslI3d8RHPWzXiQpc3DlnsD468Wy+g1p58S9WTL7yItG5E5DgPtxOa/v7u5sgSOP62ARWr+GFimUcEsurX/wraQcslVFxgVIdhSgg243GZfW7EBAo+BCwqFW3L+hSYGf0pYhAsT9ghESFo7CHRXNIq5ch86cCxw0FI0ZXdOEQlQtQCNMjqjwIvi6YgaSV/9qENIQA75VXCzxdBIgJ005NBF2rBz23MxKKVMN/14otYezXyYR4bAuccOfFFK80lZfWDAESAGAhQOAlvpFU1yKUmnJikuUjXNc5J0GSZT0iUE3gf9+IejRwO+duYay+3DzkWBIPSYkZHYc1h7NLGB0IEjPahHP0Zl9A4TrW2TJVmBYNTneJfSkGmKoi1ayjmqIjlZcaGwFxxtbjB1ymd2ihVprgaWEqqHUzZajSYFKYq3J7FgZneqn1zAgtxIomQEqilTFjcYcibimlodUjrKrXqyOWqZFAUAADs=); 12 | padding-top: 1em; 13 | color: #333333; 14 | width: 700px; 15 | margin: auto; 16 | } 17 | 18 | .post { 19 | background-color: #efefef; 20 | padding-bottom: 4px; 21 | border-bottom: 4px; 22 | } 23 | header, footer, div, h1 { 24 | background-color: #efefef; 25 | padding: 4px; 26 | } 27 | 28 | .meta { 29 | padding: 4px; 30 | background-color: #ccffcc; 31 | border-bottom: 1px solid #000; 32 | } 33 | a {color: #c90000; font-weight: bold} 34 | 35 | .msg { 36 | padding-left: 12px; 37 | padding-right: 8px; 38 | text-align: justify; 39 | } 40 | 41 | img { 42 | max-width: 500px; 43 | text-align: center; 44 | } 45 | 46 | table { 47 | border-collapse: collapse; 48 | background-color: #efefef; 49 | } 50 | 51 | th, td { 52 | padding: 4px; 53 | border: 1px solid black; 54 | } 55 | th { 56 | text-align: right; 57 | } 58 | 59 | .thread-title { color: #ff0000; 60 | font-weight: bold;} 61 | -------------------------------------------------------------------------------- /_dox/HELP.txt: -------------------------------------------------------------------------------- 1 | # multichan modules 2 | 3 | * app.py 4 | * settings.py 5 | * daemon.py 6 | 7 | * utils.py 8 | * viewer.py 9 | * writer.py 10 | * refresh.py 11 | * whitelist.py 12 | 13 | * pagemaker.py 14 | * home.py 15 | * tags.py 16 | * atom.py 17 | * tripcode.py 18 | 19 | * backup.py 20 | * mod.py 21 | 22 | * ips.txt 23 | * log.txt 24 | * delete.txt 25 | * bans.txt 26 | 27 | 28 | app.py: 29 | * Flask entry point 30 | * Handle 404 page, static, /api/ 31 | 32 | settings.py: 33 | * Configure a few things. Name, URL, etc 34 | * Set "friends" to download posts from 35 | * Set how often to scrape remote servers 36 | * Decide whether multichan is text or imageboard 37 | 38 | daemon.py: 39 | * Sync and rebuild databases -- refresh.linksites() 40 | * Make sure entries in delete.txt are deleted 41 | 42 | utils.py: 43 | * A few simple utilities. 44 | * Unix time to ISO8601 45 | * HTTP get / get over Tor 46 | * Image URL to image attachment 47 | 48 | viewer.py: 49 | * List hosts 50 | * List hosts' threads 51 | * List all threads 52 | * Display a thread 53 | 54 | writer.py: 55 | * Create a thread 56 | * Reply to a thread 57 | * Update the database 58 | 59 | refresh.py: 60 | * rebuilds thread indexes from the bottom up 61 | * scrapes remote threads/tags 62 | * grabs new posts in followed threads and new threads 63 | 64 | whitelist.py: 65 | * maintains a list of who's allowed to post and who's banned 66 | * generates CAPTCHA images 67 | 68 | -- 69 | pagemaker.py: 70 | * wrap page in header and footer with mk() 71 | * prepare file like ./html/file.html with html("file") 72 | home.py: 73 | * Homepage 74 | * Rules page, About page, Friends page 75 | * /stats/ 76 | 77 | tags.py: 78 | * load tag lists 79 | * build tag lists from head.txt from bottom up 80 | * present tag index / tag's threads 81 | 82 | atom.py: 83 | * make ATOM feeds per global, host, thread, tag, comments 84 | * show index of ATOM feeds 85 | 86 | tripcode.py: 87 | * generate tripcodes and secure tripcodes 88 | * command line tripcodes like "python3 tripcode.py3 hello" 89 | 90 | -- 91 | backup.py: 92 | * backup config, threads, html, static data to " ./bak/" 93 | 94 | mod.py: 95 | * executed as " python3 mod.py" 96 | * deletes threads/comments marked for deletion 97 | * good for mass deletes 98 | 99 | -- 100 | See also ./_dox/moderation.txt 101 | 102 | log.txt: 103 | * IP log of new comments/threads on current server 104 | * host thread# reply-number ipaddress date<>author<>messages 105 | 106 | ips.txt: 107 | * List of IPs attempting Captcha 108 | * Lists time an IP was approved to post, if approved 109 | * Delete an IP to remove posting privileges 110 | * attempt-time ip-address captcha-code time-approved 111 | 112 | bans.txt: 113 | * On IP or range (a.b.c.d and a.b.c ) per line; 114 | * Banned users always fail captcha 115 | * Banned users should not be present in ip.txt 116 | 117 | delete.txt: 118 | * Threads/comments here will be erased or wiped 119 | * A reason can be given after ";" semicolon 120 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | Irc: 2 | * irc.rizon.net #0chan 3 | * http://qchat.rizon.net/?channels=0chan 4 | * irc.libera.chat #multich 5 | 6 | Matrix: 7 | * [#multichan:matrix.org](https://matrix.to/#/#multichan:matrix.org) 8 | 9 | Wiki: 10 | * https://tanasinn.vip/multich 11 | 12 | # Easy how-to-install: 13 | 14 | 1. git clone https://github.com/153/multichan 15 | 2. pip3 install -r requirements.txt 16 | 3. edit settings.py 17 | 4. python3 app.py 18 | 19 | Also at https://bitbucket.org/796f/multichan/ 20 | 21 | ./_dox/NGINX.txt and ./_dox/APACHE.txt have information on configuring a 22 | webserver to use a reverse proxy to assign a domain name and port (like 23 | 80 or 443) to the multichan server. 24 | 25 | # Basic info 26 | Script runs by default at 127.0.0.1 port 5150. Using nginx or lighttpd, 27 | it's fairly simple to point a domain name and port 80 or 443 at the 28 | server. 29 | 30 | Modify settings.py setting "name" to set the name of your board; 31 | you should also set your URL to what domain name you are giving 32 | your multichan server. After that, you can add tags to the tags 33 | array to suggest the themes of your server, and add boards you want 34 | to link with in the "friends" part of the configuration. 35 | 36 | Finally, run `python3 refresh.py` to prepare to federate, and 37 | `python3 app.py` to run your multichan server. You should be able 38 | to view it at the IP address of the server followed by "colon 5150". 39 | This would be http://localhost:5150/ on the machine running it. 40 | Solve the captcha at "/captcha" and try making a post. 41 | 42 | The script `backup.py` creates a directory, `./bak/`, which contains 43 | the current site's settings file, whitelist file, delete file, 44 | ban file, post log, and thread database. ( settings.py , ips.txt , 45 | log.txt , ./delete.txt , ./bans.txt , ./threads/ ). 46 | 47 | To delete comments / threads, add them to " `./delete.txt` " in the format 48 | "site thread" or "site thread site comment". For example, "local 0" or 49 | "local 0 test 3" (to remove the 3rd comment from server "test" in local's 50 | thread "0"). Run `python3 mod.py` to delete offending files; leave entries 51 | from remote boards in `delete.txt`to prevent them from being re-downloaded. 52 | 53 | Edit the files in ./html/ to add some customization. 54 | 55 | Users must solve a captcha in order to publish a thread or comment. 56 | Users who attempt to solve the captcha are logged in `ips.txt` by 57 | default; a user who is unable to solve the captcha can have the key 58 | provided by the administrator or have the test overridden if the user 59 | provides their IP address. To ban a user, remove their IP from `ips.txt` 60 | (find it via `log.txt` ) and then add either their IP address ( `a.b.c.d` ) 61 | or their ip range ( `a.b.c` ). You can follow it with a comment or not, 62 | your choice. Now, if the banned user tries to post, they'll get nothing 63 | but failed captcha messages. How frustrating! 64 | 65 | The file `log.txt` contains a list in the format 66 | `board thread replynum ipaddress`, which can be used to help block 67 | spammers. It does not filter spam from remote boards; to do that, 68 | modify the `delete.txt` file. A web-based admin panel is coming. 69 | 70 | Tags can be manually set by the administrator per-thread by modifying the 71 | `./threads/site/thread/head.txt` file's second line with space separated 72 | keywords, and then executing `python3 tags.py` and/or `python3 refresh.py`. 73 | 74 | # Planned features 75 | * Anti-spam 76 | * Textboard archive --> Multich relay 77 | * "chan" style board view 78 | * "tree" style board view (usenet, reddit, ayashii) 79 | * Better API 80 | * Desktop client 81 | 82 | # Directory structure 83 | ``` 84 | in ./threads/ : 85 | 86 | ./site/######/head 87 | title 88 | tag1 tag2 tag3 ... 89 | ./site/######/sitename 90 | time<>name<>comment 91 | ./site/######/list 92 | site time 93 | 94 | ./site/list 95 | optime lastreply localreplies allreplies title 96 | ./site/tags 97 | tag thread thread thread ... 98 | 99 | ./list 100 | site optime lastreply localreplies allreplies title 101 | ./friends 102 | site url 103 | ./tags 104 | tag site-thread ... 105 | ``` 106 | 107 | # Help! My board is broken! 108 | Move your threads directory somewhere safe and make a new one. 109 | 110 | In the `./threads/` directory: 111 | 112 | 1. Make a new directory, `local/0/` 113 | 2. Create `local/0/head.txt` 114 | 3. Write `Hello world`, newline, `none` 115 | 4. Create `local/0/local.txt` 116 | 5. Write `0<>none<>none` 117 | 6. Create `local/0/list.txt` 118 | 7. Write `local 0` 119 | 8. Execute `python3 refresh.py` in multich directory. 120 | 121 | Removing files from ./archive/ (e.g. `rm ./archive/*` ) may also be 122 | useful. 123 | -------------------------------------------------------------------------------- /home.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request 2 | from datetime import date 3 | from datetime import timedelta 4 | import tripcode as tr 5 | import pagemaker as p 6 | import settings as s 7 | 8 | home = Blueprint("home", __name__) 9 | 10 | @home.route('/', strict_slashes=False) 11 | def hello_world(): 12 | return p.mk(p.html("home").format(s.name)) 13 | 14 | @home.route('/rules') 15 | def rules(): 16 | return p.mk(p.html("rules")) 17 | 18 | @home.route('/about') 19 | def about(): 20 | return p.mk(p.html("about")) 21 | 22 | @home.route('/trip/', methods=['POST', 'GET']) 23 | @home.route('/trip/', methods=['POST', 'GET']) 24 | def do_trip(trip=None): 25 | if request.method == "POST": 26 | trip = request.form["trip"] 27 | return "
    ".join([ 28 | "#" + trip, 29 | "!" + tr.mk(trip), 30 | "
    ##" + trip, 31 | "!!" + tr.sec(trip)]) 32 | elif trip: 33 | return "
    ".join([ 34 | "#" + trip, 35 | "!" + tr.mk(trip), 36 | "
    ##" + trip, 37 | "!!" + tr.sec(trip)]) 38 | return """
    39 | """ 40 | 41 | 42 | @home.route('/stats/') 43 | def counter(): 44 | with open("./static/counter.txt", "r") as cnt: 45 | cnt = int(cnt.read().strip()) 46 | with open("./static/counter.txt", "w") as update: 47 | update.write(str(cnt + 1)) 48 | with open(s.bans, "r") as bans: 49 | bans = bans.read().splitlines() 50 | with open(s.delete, "r") as dele: 51 | dele = dele.read().splitlines() 52 | 53 | with open("./threads/list.txt", "r") as threads: 54 | threads = threads.read().splitlines() 55 | with open("./threads/tags.txt", "r") as tags: 56 | tags = tags.read().splitlines() 57 | tcnt = str(len(threads)) 58 | lcnt = str(len([t for t in threads if t[:6] == "local "])) 59 | rcnt = str(sum([int(t.split(" ")[3]) for t in threads])) 60 | acnt = str(sum([int(t.split(" ")[4]) for t in threads])) 61 | dcnt = str(len(dele)) 62 | bcnt = str(len(bans)) 63 | tags = str(len(tags)) 64 | atags = str(len(s.tags)) 65 | 66 | page = [] 67 | page.append(" ".join([f"

    You are visitor #{cnt+1}", 68 | "to this stats page at", s.url, "
      "])) 69 | page.append(" ".join(["
    • ", str(len(s.friends)), "friend servers"])) 70 | page.append(" ".join(["
    • ", atags, "featured tags"])) 71 | page.append(" ".join(["
    • ", tags, "unique tags

      "])) 72 | page.append(" ".join(["

    • ", lcnt, "local threads"])) 73 | page.append(" ".join(["
    • ", tcnt, "known threads

      "])) 74 | page.append(" ".join(["

    • ", rcnt, "local replies"])) 75 | page.append(" ".join(["
    • ", acnt, "total replies

      "])) 76 | page.append(" ".join(["

    • ", dcnt, "deleted posts"])) 77 | page.append(" ".join(["
    • ", bcnt, "banned addresses"])) 78 | page.append("
    ") 79 | return p.mk("\n".join(page)) 80 | 81 | @home.route('/friends') 82 | def friends(): 83 | title = "

    Friends of " + s.name 84 | title += "

    " + s.url 85 | if s.images: 86 | title += f"

    images: {s.ihost}
    " 87 | else: 88 | title += "

    Images not enabled!" 89 | if s.boards: 90 | title += "
    Boards enabled" 91 | else: 92 | title += "
    Boards not enabled!" 93 | title += "

    " 94 | title += "Friends are other multichan websites that " 95 | title += "this server downloads threads and comments from." 96 | flist = [] 97 | fstring = "
  • {0} {1}" 98 | for f in s.friends: 99 | flist.append(fstring.format(f, s.friends[f])) 100 | flist = "
      " + "\n".join(flist) + "
    " 101 | page = title + flist + "
  • " 102 | return p.mk(page) 103 | 104 | def norm2dqn(year, month, day): 105 | dqnday = date(1993, 8, 31) 106 | norm = date(year, month, day) 107 | print(norm-dqnday) 108 | return (norm - dqnday).days 109 | 110 | def dqn2norm(day): 111 | dqnday = date(1993, 8, 31) 112 | norm = dqnday + timedelta(days=day) 113 | print(norm) 114 | return norm 115 | 116 | @home.route('/dqn//', methods=['POST', 'GET']) 117 | @home.route('/dqn/', methods=['POST', 'GET']) 118 | def dqn(dokyun=None, mode=None): 119 | if request.method == "POST": 120 | dokyun = request.form["dqn"] 121 | mode = request.form["mode"] 122 | 123 | if dokyun: 124 | try: 125 | if mode == "n": 126 | print(dokyun) 127 | dokyun = [int(i) for i in dokyun.split("-")] 128 | print(dokyun) 129 | dokyun = norm2dqn(*dokyun) 130 | elif mode == "d": 131 | dokyun = int(dokyun) 132 | dokyun = dqn2norm(dokyun) 133 | return str(dokyun) + "

    (back)" 134 | except: 135 | return "/dqn/yyyy-mm-dd" 136 | return """ 137 | yyyy-mm-dd for normal; just day for DQN. 138 |
    139 | 140 |
    141 | 142 |
    143 |
    144 |

    """ 145 | -------------------------------------------------------------------------------- /html/about.html: -------------------------------------------------------------------------------- 1 |

    about

    2 |
    3 |
    4 | Note: this project is a work in progress! 5 |
    Follow our development on Bitbucket 6 |
    7 |
    8 | multichan, or multiple channels[1], is a novel 9 | decentralized platform for conversations. Unlike other social media platforms, 10 | a) registration is not necessary to participate in conversation; 11 | b) the conversations are not "owned" or controlled by any single 12 | entity; c) manipulative algorithms and annoying advertisements 13 | are not included in the software. 14 |

    15 | People wishing to participate in the greater community can connect to any 16 | website running our software that is choosing to participate in our 17 | decentralization features. Simply put, our conversations are shared 18 | between any computers that want to help keep the discussion going. 19 | Connecting to one website will let you see conversations from other websites, 20 | and posting your messages to one website triggers a domino effect that pushes 21 | your messages to other websites. 22 |

    23 | This decentralization might sound like an unusual concept to base a 24 | chat software around, but the author of the software thinks that it's worth 25 | investigating because it eliminates many of the problems associated with 26 | running group chats in an elegant way. Anyone with a desktop computer is able 27 | to grow the network, making censorship more difficult.

    Decentralizing a 28 | network also reduces the overall cost of maintaining the platform. If you 29 | choose to help run the network with us, you likely won't even see your electric 30 | bill increase; you can also manage the content on your server entirely as you 31 | like. Be warned, however, that unpopular moderation decisions might drive users 32 | away from your server, or influence other server operators to ignore output 33 | from your server, limiting the outreach of content your users provide. 34 |

    35 | Read the Multichan document for 36 | more information. 37 |


    38 |

    Formatting and Easter eggs

    39 |
      40 |
    • The captcha must be solved before you can 41 | start new discussions or reply to existing ones. 42 |
    • Atom feeds let you easily subscribe to Multichan 43 | from desktop, mobile, and chatrooms. Read more about Atom feeds on 45 | Wikipedia. 46 |
    • Hackers may enjoy the public /api/ directory. 47 |
    • If a friends list shows the "image:" link at the 48 | top of the page, the first image URL from that host in a post will show 49 | up as an attachment at the bottom of the post. This pseudo-upload mode 50 | will be tweaked more in the future. 51 |
    • If a friends list says Boards Enabled, 52 | go to /b/ to get the board list, /b/board/ to see the board "/board/", 53 | and /b/board/password to either claim "/board/" with password, or 54 | login to the admin panel for "/board/" with password. (You may need 55 | to refresh after claiming a board to login) 56 |

      57 |

    • Tags should be lowercase and only letters, numbers, or "_" 58 |
    • Tags at the top of the tags list in bold are the admin's favorite tags. 59 | These are suggested topics if you don't know what to write a new post 60 | about. 61 |
    • Tag views can be combined using the "+"; example: 62 |
      http://0chan.vip/tags/meta+tech/ <-- view "meta" and "tech" 63 |

      64 |

    • A comment can be linked to or replied to by clicking on its number. 65 | For comments from the site that created the discussion, replies 66 | are formatted like >>1 >>2 >>3 >>4 . 67 |
    • Replies from other sites are referenced by their URL and reply number 68 | in that site's reply list. This can be a little confusing to figure 69 | out, so just click the comment number to let multichan figure it out 70 | for you. An example may look like >>http://0chan.vip/3

      71 |

    • A tripcode can be entered by following your name with the "#" 72 | sign followed by a password (up to 8 characters).

      73 | (example) name#test -> name !.CzKQna1OU 74 |
      (example) dev#Multich -> dev !g4hkX2f/2g 75 |

      76 | Do you see the colored code next to the name? Your password will 77 | be translated to a "hash" using a public method. Only someone who knows 78 | the same password can post using the same tripcode. This form of 79 | pseudo-registration can be used on most textboards and imageboards, 80 | not just multichan. 81 |

    • You can test your tripcode before you use it, 82 | to see how it'll show up on the board. 83 |
    • A secure tripcode can be generated that (theoretically) is 84 | unique to your current site, by mixing your password with either a 85 | consistent secret "salt" password known only by the admin, or by 86 | using an alternative "hashing" method that makes it very difficult 87 | for the public to reverse engineer your password to make posts 88 | imitating you. 89 |
    90 | 91 | 92 |
    93 | [1] - The name is a reference to "second channel" (2ch), a popular discussion 94 | website that has some features that resemble those in multich. 95 | 96 |
    97 | -------------------------------------------------------------------------------- /tags.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Blueprint, request 3 | import settings as s 4 | import pagemaker as p 5 | 6 | tags = Blueprint("tags", __name__) 7 | tlist = s.tags 8 | flist = s.friends 9 | 10 | # tags_host("host") 11 | # tags_load() 12 | 13 | # tags_view(["tags"]) 14 | # tags_addthread("num", ["tags"]) 15 | 16 | def tags_load(host=""): 17 | tagp = "/".join(["./threads", host, "tags.txt"]) 18 | with open(tagp, "r") as tags: 19 | tags = tags.read().splitlines() 20 | tags = [x.split(" ") for x in tags] 21 | tagdb = {} 22 | for t in tags: 23 | tag = t[0] 24 | threads = t[1:] 25 | if "-" in threads[0]: 26 | threads = [x.split("-") for x in threads] 27 | if len(threads) and threads != [""]: 28 | if tag in tagdb: 29 | tagdb[tag] += [t for t in threads if t not in tagdb[tag]] 30 | print(tagdb[tag]) 31 | else: 32 | tagdb[tag] = threads 33 | else: 34 | tagdb[tag] = [] 35 | return tagdb 36 | 37 | def tags_threads(tags=[]): 38 | db = tags_load() 39 | threads = [] 40 | tmp = [] 41 | for t in tags: 42 | if t in db: 43 | tmp += db[t] 44 | else: 45 | tmp += [] 46 | for t in tmp: 47 | if t not in threads and len(t): 48 | threads.append(t) 49 | return threads 50 | 51 | def mkhost(host): 52 | hostp = "/".join(["./threads", host]) 53 | tagp = hostp + "/tags.txt" 54 | threads = [x.path for x in os.scandir(hostp) if x.is_dir()] 55 | tagd = {} 56 | for thread in threads: 57 | num = thread.split("/")[3] 58 | head = thread + "/head.txt" 59 | with open(head, "r") as head: 60 | tags = head.read().splitlines() 61 | try: 62 | tags = tags[1].split(" ") 63 | except: 64 | tags = ["random"] 65 | for t in tags: 66 | if t not in tagd: 67 | tagd[t] = [] 68 | tagd[t].append(num) 69 | tagf = [" ".join([t, *tagd[t]]) for t in tagd] 70 | with open(tagp, "w") as tags: 71 | tags.write("\n".join(tagf)) 72 | return 73 | 74 | def mksite(remake=0): 75 | tdb = {x: [] for x in tlist} 76 | for f in flist: 77 | if remake: 78 | mkhost(f) 79 | tpath = "/".join(["./threads", f, "tags.txt"]) 80 | with open(tpath, "r") as tag: 81 | tag = tag.read().splitlines() 82 | tag = [x.split(" ") for x in tag] 83 | tag = {x[0]: x[1:] for x in tag} 84 | for t in tag: 85 | tag[t] = [[f, x] for x in tag[t]] 86 | if t not in tdb: 87 | tdb[t] = [] 88 | tdb[t].append(tag[t]) 89 | tagl = [] 90 | for t in tdb: 91 | tdb[t] = [y for x in tdb[t] for y in x] 92 | entry = " ".join(["-".join(x) for x in tdb[t]]) 93 | tagl.append(" ".join([t, entry])) 94 | tagl = "\n".join(tagl) 95 | with open("./threads/tags.txt", "w") as tagf: 96 | tagf.write(tagl) 97 | return 98 | 99 | @tags.route('/tags/') 100 | def tag_index(): 101 | tdb = tags_load() 102 | sentry = "
  • {0} ({1} discussions)" 103 | oentry = "
  • {0} ({1} discussions)" 104 | result = ["

    Conversation tags

    ", 105 | "Bolded tags are the default tags selected by the site admin."] 106 | result.append("
    Tags can be combined with the '+' plus sign in URL.") 107 | links = ["
      "] 108 | site_tags = {t : len(tdb[t]) for t in tlist} 109 | site_tags = {k: v for k, v in sorted(site_tags.items(), 110 | key= lambda x: int(x[1]))[::-1]} 111 | all_tags = {t : len(tdb[t]) for t in list(tdb.keys()) if t not in tlist} 112 | all_tags = {k: v for k, v in sorted(all_tags.items(), 113 | key= lambda x: int(x[1]))[::-1]} 114 | 115 | for t in site_tags: 116 | links.append(sentry.format(t, site_tags[t])) 117 | if site_tags[t] == 1: 118 | links[-1] == links[-1].replace("s)", ")") 119 | links.append("
      ") 120 | cnt = 0 121 | last = 0 122 | for t in all_tags: 123 | cnt = int(all_tags[t]) 124 | if (cnt < last) and (cnt == 1): 125 | links.append("
      ") 126 | links.append(oentry.format(t, all_tags[t])) 127 | if all_tags[t] == 1: 128 | links[-1] = links[-1].replace("s)", ")") 129 | last = cnt 130 | links.append("
    ") 131 | result.append("\n".join(links)) 132 | result = p.mk("\n".join(result)) 133 | return result 134 | 135 | @tags.route('/tags//') 136 | def tag_page(topic): 137 | line = "{0} " \ 138 | + "{5}" \ 139 | + "{4}" 140 | result = [] 141 | ot = "".join(topic) 142 | if "+" in topic: 143 | topic = topic.split("+") 144 | else: 145 | topic = [topic] 146 | result.append("

    #" + " #".join(topic) + "

    ") 147 | result.append(" + create new
    ") 148 | result.append("Note: tags can be combined using the " 149 | "+ (plus sign) in the URL
    ") 150 | result.append("

    ") 151 | result.append("
    origintitlereplies") 152 | threads = tags_threads(topic) 153 | with open("./threads/list.txt") as site: 154 | site = site.read().splitlines() 155 | site = [s.split(" ") for s in site] 156 | site = [[*s[:5], " ".join(s[5:])] for s in site 157 | if [s[0], s[1]] in threads] 158 | result[0] += " (" + str(len(site)) + " threads)" 159 | test = "\n".join([line.format(*t) for t in site]) 160 | result.append(test) 161 | result.append("
    ") 162 | result = p.mk("\n".join(result)) 163 | return result 164 | 165 | if __name__ == "__main__": 166 | mksite(1) 167 | 168 | # tags_load() -> db 169 | # tags_threads([]) -> threads 170 | # mkhost() 171 | # mksite() 172 | # tag_index() 173 | 174 | #print("\n0chan:") 175 | #tags_load("0chan") 176 | -------------------------------------------------------------------------------- /whitelist.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import random 4 | import string 5 | import time 6 | import settings as s 7 | import pagemaker as p 8 | from captcha.image import ImageCaptcha 9 | from flask import Blueprint 10 | from flask import request 11 | 12 | whitelist = Blueprint("whitelist", __name__) 13 | image = ImageCaptcha(fonts=['droid.ttf']) 14 | conf = s.wlist 15 | klen = 5 16 | tnow = int(time.time()) 17 | 18 | def get_ip(): 19 | return request.headers.get('X-Forwarded-For', request.remote_addr) 20 | 21 | def randstr(length): 22 | letters = "bcefgkmopswxz" 23 | key = "".join(list(random.choice(letters) for i in range(length))) 24 | return key 25 | 26 | def ldlog(): 27 | with open(conf, "r") as log: 28 | log = log.read().splitlines() 29 | log = [i.split(" ") for i in log] 30 | log = {i[1] : i for i in log} 31 | return log 32 | 33 | def genkey(ip): 34 | entry = [str(int(time.time())), ip, str(randstr(klen))] 35 | image.write(entry[2], f'./static/cap/{ip}.png') 36 | return entry 37 | 38 | def addlog(ip, ig=0): 39 | log = ldlog() 40 | if ip not in log or ig: 41 | entry = genkey(ip) 42 | log[ip] = entry 43 | fi = "\n".join([" ".join(log[x]) for x in log]) 44 | with open(conf, "w") as iplog: 45 | iplog.write(fi) 46 | return log 47 | 48 | def check_db(ip): 49 | print(""" headers = { 50 | 'Key': api_key, 51 | 'Accept': 'application/json', 52 | } 53 | 54 | params = { 55 | 'maxAgeInDays': days, 56 | 'ipAddress': IP, 57 | 'verbose': '' 58 | } 59 | 60 | r = requests.get('https://api.abuseipdb.com/api/v2/check', 61 | headers=headers, params=params) 62 | """) 63 | 64 | def approve(ip=0, key=""): 65 | if not ip: 66 | ip = get_ip() 67 | now = str(int(time.time())) 68 | log = ldlog() 69 | with open(s.bans, "r") as bans: 70 | bans = bans.read().splitlines() 71 | bans = [b.split(" ")[0] if " " else b for b in bans] 72 | iprange = ".".join(ip.split(".")[:3]) 73 | for b in bans: 74 | if ip.startswith(b): 75 | return False 76 | if ip in log: 77 | if len(log[ip]) == 3: 78 | if log[ip][2] != key: 79 | return False 80 | log[ip].append(now) 81 | newl = [" ".join(log[k]) for k in log] 82 | with open(conf, "w") as log: 83 | log.write("\n".join(newl)) 84 | return True 85 | else: 86 | return True 87 | return False 88 | 89 | @whitelist.route('/captcha/') 90 | def show_captcha(hide=0, redir=''): 91 | ip = get_ip() 92 | mylog = addlog(ip) 93 | logtxt = json.dumps(mylog) 94 | out = "" 95 | if not hide: 96 | out = p.html("captcha") 97 | out += p.html("captcha-form").format(mylog[ip][1], redir) 98 | if hide: 99 | return out 100 | return p.mk(out) 101 | 102 | @whitelist.route('/captcha/refresh') 103 | def refresh(): 104 | ip = get_ip() 105 | mylog = addlog(ip, 1) 106 | return "" 107 | 108 | @whitelist.route('/captcha/check', methods=['POST', 'GET']) 109 | def check(redir=""): 110 | key = request.args.get('key').lower() 111 | ip = get_ip() 112 | log = ldlog() 113 | out = approve(ip, key) 114 | out = json.dumps(out) 115 | if out == "false": 116 | out = "You have filled the captcha incorrectly." 117 | out += "

    Please solve the captcha." 118 | if out == "true": 119 | out = "You filled out the captcha correctly!" 120 | out += "

    Please review the rules before posting." 121 | out += f"


    back" 122 | if os.path.isfile(f"./static/cap/{ip}.png"): 123 | os.remove(f"./static/cap/{ip}.png") 124 | 125 | return out 126 | 127 | def flood(limit=60, mode="comment"): 128 | ip = get_ip() 129 | tnow = str(int(time.time())) 130 | with open(s.log, "r") as log: 131 | log = log.read().splitlines() 132 | try: log = [x.split() for x in log] 133 | except: return False 134 | log = [x for x in log if x[3] == ip] 135 | if mode == "comment": 136 | if not log: return False 137 | try: post = log[-1][3:5] 138 | except: return False 139 | post[1] = post[1].split("<>")[0] 140 | last = post 141 | elif mode == "thread": 142 | try: threads = [x for x in log if (x[0] == "local") and (x[2] == "1")] 143 | except: return False 144 | if not threads: return False 145 | thread = threads[-1][3:5] 146 | thread[1] = thread[1].split("<>")[0] 147 | last = thread 148 | pause = int(tnow) - int(last[1]) 149 | diff = limit - pause 150 | if diff > 60: 151 | diff = f"{diff//60} minutes {diff %60}" 152 | if pause < limit: 153 | return "Error: flood detected." \ 154 | + f"

    Please wait {diff} seconds before trying to post again." 155 | return False 156 | 157 | # host thread replynumber ip datetime name message 158 | # add "ip's post log" -- (group 3) > (group 4) 159 | 160 | def flood_control(mode="comment"): 161 | user = {"comment" : 60, "thread" : 60*60} 162 | site = {"comment" : 20, "thread" : 40*60} 163 | 164 | user_rate(user[mode], mode) 165 | 166 | def get_comment_log(): 167 | tnow = str(int(time.time())) 168 | logpath = s.log 169 | with open(logpath, "r") as log: 170 | log = log.read().splitlines()[-14:-10] # changeme 171 | try: log = [x.split() for x in log] 172 | except: return False 173 | log = [[*L[:4], *L[4].split("<>")] for L in log] 174 | for L in log: 175 | print(L) 176 | 177 | return log 178 | 179 | def user_rate(limit=60, mode="comment"): 180 | ip = get_ip() 181 | log = get_comment_log() 182 | return 183 | log = [i for i in log if i == ip] 184 | print(log) 185 | 186 | 187 | # site_comment_rate 20 seconds 20 188 | # user_comment_rate 1 minute 60 189 | # site_thread_rate 40 minutes 60 * 40 190 | # user_thread_rate 1 hour 60 * 60 191 | 192 | # add "recent posts" -- (group 4) 193 | -------------------------------------------------------------------------------- /writer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import re 4 | from flask import Blueprint, request 5 | import refresh 6 | import tags 7 | import whitelist 8 | import tripcode 9 | import settings as s 10 | import pagemaker as p 11 | 12 | 13 | writer = Blueprint("writer", __name__) 14 | tdir = "threads" 15 | 16 | with open("templ/newt.t", "r") as newtt: 17 | newtt = newtt.read() 18 | 19 | def nametrip(name): 20 | if "#" in name: 21 | name = name.split("#")[:3] 22 | print(name) 23 | if len(name) > 2: 24 | if len(name[1]): 25 | name[1] = tripcode.mk(name[1]) 26 | name[1] += " !!" + tripcode.sec(name[2]) 27 | else: 28 | name[1] = "!" + tripcode.sec(name[2]) 29 | 30 | name = name[:2] 31 | else: 32 | name[1] = tripcode.mk(name[1]) 33 | if not len(name[0]): 34 | name[0] = "Anonymous" 35 | name = " !".join(name) + "" 36 | return name 37 | 38 | def log(host, thread, postnum, reply): 39 | ip = whitelist.get_ip() 40 | line = " ".join([host, thread, postnum, ip, reply]) 41 | iplog = "" 42 | with open(s.log, "r") as logger: 43 | postlog = logger.read() 44 | postlog += line 45 | with open(s.log, "w") as logger: 46 | logger.write(postlog) 47 | 48 | def mk_op(title="", tag="random", author="Anonymous", msg=""): 49 | title = title[:s._short] 50 | tag = tag[:s._short].lower() 51 | author = author[:s._short] 52 | msg = msg[:s._long] 53 | title = title.replace("&", "&").replace("<", "<") 54 | author = author.replace("&", "&").replace("<", "<") 55 | msg = msg.replace("&", "&").replace("<", "<") 56 | msg = msg.replace("\n","
    ").replace("\r","") 57 | 58 | if not title.strip() or not msg.strip(): 59 | return "Please write a message to create a new conversation." 60 | if not author: 61 | author = "Anonymous" 62 | 63 | author = nametrip(author) 64 | pat = re.compile(r'^[ A-Za-z0-9_-]*$') 65 | tag = " ".join(list(set(re.findall(r'\w+', tag)))) 66 | if len(tag) == 0: 67 | tag = "random" 68 | # author, tstamp, msg 69 | tnow = str(int(time.time())) 70 | 71 | t_loc = ["local", tnow] 72 | b_pat = f"./{tdir}/local/" 73 | t_pat = b_pat + tnow + "/" 74 | os.mkdir(t_pat) 75 | 76 | head = [title, tag] 77 | files = {"head": t_pat + "head.txt", 78 | "list": t_pat + "list.txt", 79 | "op": t_pat + "local.txt"} 80 | 81 | with open(files["head"], "w") as headf: 82 | headf.write("\n".join(head)) 83 | if os.path.isfile(files["list"]): 84 | with open(files["list"], "r") as listf: 85 | li = li.read().splitlines() 86 | else: 87 | li = [] 88 | li.append(f"local {tnow}") 89 | li = "\n".join(li) + "\n" 90 | with open(files["list"], "w") as listf: 91 | listf.write(li) 92 | 93 | rline = "<>".join([tnow, author, msg]) + "\n" 94 | with open(files["op"], "w") as opf: 95 | opf.write(rline) 96 | with open(b_pat + "list.txt", "r") as bind: 97 | bind = bind.read().splitlines() 98 | upd = [t_loc[1], t_loc[1], "1 1", title] 99 | bindex = [b for b in bind if len(b) > 4] 100 | bindex = [" ".join(upd)] + bindex 101 | bindex = "\n".join(bindex) 102 | with open(b_pat + "list.txt", "w") as bind: 103 | bind.write(bindex) 104 | log("local", tnow, "1", rline) 105 | 106 | refresh.mksite() 107 | tags.mkhost("local") 108 | tags.mksite() 109 | 110 | def rep_t(host, thread, now, author, msg): 111 | # open host/thread/local 112 | # append post json 113 | # update list.txt 114 | # update host/list 115 | author = author.replace("&", "&").replace("<", "<") 116 | if not author: 117 | author = "Anonymous" 118 | else: 119 | author = nametrip(author) 120 | msg = msg[:s._long] 121 | tdir = f"./threads/{host}/{thread}/" 122 | if os.path.isfile(tdir + "lock"): 123 | return "Error: thread is locked!" 124 | tnow = now 125 | msg = msg.replace("&", "&").replace("<", "<") 126 | msg = msg.replace("\n","
    ").replace("\r","") 127 | rline = "<>".join([tnow, author, msg]) + "\n" 128 | cnt = 0 129 | with open(tdir + "local.txt", "a") as t: 130 | t.write(rline) 131 | with open(tdir + "local.txt", "r") as t: 132 | t = t.read().splitlines() 133 | cnt = len(t) 134 | with open(tdir + "list.txt", "a") as tlist: 135 | tlist.write(f"local {tnow}\n") 136 | log(host, thread, str(cnt), rline) 137 | 138 | def update_host(host, thread, now, wr=1): 139 | tpath = f"./threads/{host}/list.txt" 140 | with open(tpath, "r") as tf: 141 | tf = tf.read().splitlines() 142 | tnum = thread 143 | tf = [t.split(" ") for t in tf] 144 | tfd = {t[0]: t[1:] for t in tf} 145 | tfd[tnum][0] = str(now) 146 | tfd[tnum][1] = str(int(tfd[tnum][1]) +1) # local 147 | tfd[tnum][2] = str(int(tfd[tnum][2]) +1) # total 148 | newl = [] 149 | for t in tfd: 150 | newl.append([t, *tfd[t]]) 151 | newl.sort(key=lambda x:x[1], reverse=True) 152 | newl = "\n".join([" ".join(t) for t in newl]) 153 | if wr: 154 | with open(tpath, "w") as tpath: 155 | tpath.write(newl) 156 | refresh.mksite() 157 | 158 | @writer.route('/create/', methods=['POST', 'GET']) 159 | @writer.route('/create/', methods=['POST', 'GET']) 160 | def new_thread(t="random"): 161 | if request.method == 'POST': 162 | if not whitelist.approve(): 163 | return "You need to solve the " \ 164 | + "captcha before you can post." 165 | if request.form['sub'] == "Create chat": 166 | flood = whitelist.flood(s.thread, "thread") 167 | if flood: return flood 168 | mk_op(title=request.form['title'], 169 | tag=request.form['tag'], 170 | author=request.form['author'], 171 | msg=request.form['message']) 172 | return "

    " \ 173 | + "Posting thread.....

    " \ 174 | + "(back)

    " 175 | 176 | # if not t=tag , t = random 177 | if not len(t): 178 | t = "random" 179 | 180 | if not whitelist.approve(): 181 | return(p.mk(whitelist.show_captcha(1) + newtt.format(t))) 182 | return p.mk(newtt.format(t)) 183 | -------------------------------------------------------------------------------- /atom.py: -------------------------------------------------------------------------------- 1 | import time 2 | from flask import Blueprint, request 3 | import settings as s 4 | 5 | atom = Blueprint("atom", __name__) 6 | 7 | tstring = "%Y-%m-%dT%H:%M:%S+00:00" 8 | 9 | url = s.url 10 | title = s.name 11 | friends = s.friends 12 | 13 | feed_temp = """ 14 | 15 | {title} 16 | Anonymous 17 | {url} 18 | 19 | {published} 20 | """ 21 | 22 | entry_temp = """ 23 | {title} 24 | 25 | {url} 26 | {published} 27 | {published} 28 | 29 | {content} 30 | 31 | """ 32 | 33 | # server_to_list ldglobal() 34 | # site_to_list ldsite("site") 35 | # tag_to_list ldtag("tag") 36 | # thread_to_list ldthread(host, thread) 37 | # list_to_feed mkatom(title, html-url, atom-url, list) 38 | 39 | def unix2atom(unix): 40 | atom = time.strftime(tstring, time.localtime(int(unix))) 41 | return atom 42 | 43 | def ldsite(site="local"): 44 | # sitename, unixtime, atomtime, title, comment 45 | if site not in friends.keys(): 46 | return None 47 | fn = "./threads/" + site + "/list.txt" 48 | with open(fn, "r") as index: 49 | index = index.read().splitlines() 50 | index = sorted(index)[::-1] 51 | for n, i in enumerate(index): 52 | i = i.split(" ") 53 | fn = "/".join(["./threads", site, i[0], site + ".txt"]) 54 | with open(fn, "r") as op: 55 | op = op.read().splitlines() 56 | op = op[0].split("<>")[2] 57 | i[4] = " ".join(i[4:]) 58 | i[1] = time.strftime(tstring, time.localtime(int(i[0]))) 59 | index[n] = [site, i[0], i[1], i[4], op] 60 | return index 61 | 62 | def ldglobal(): 63 | flist = friends.keys() 64 | globalindex = [ldsite(f) for f in flist] 65 | globalindex = [x for y in globalindex for x in y] 66 | for n, g in enumerate(globalindex): 67 | globalindex[n][3] = f"[{g[0]}] {g[3]}" 68 | globalindex.sort(key = lambda x: x[1], reverse=True) 69 | return globalindex 70 | 71 | def ldtag(tag="random"): 72 | globalindex = ldglobal() 73 | with open("./threads/tags.txt", "r") as tags: 74 | tags = tags.read().splitlines() 75 | tags = [t.split() for t in tags] 76 | tagdb = {t[0]: t[1:] for t in tags} 77 | if tag not in tagdb: 78 | return 79 | tag = [t.split("-") for t in tagdb[tag]] 80 | tagindex = [t for t in globalindex if t[:2] in tag] 81 | return tagindex 82 | 83 | def ldthread(host, thread): 84 | hosts = [] 85 | data = [] 86 | thread_dir = "/".join(["./threads", host, thread]) 87 | index = thread_dir + "/list.txt" 88 | with open(index, "r") as index: 89 | index = index.read().splitlines() 90 | index = [i.split(" ") for i in index] 91 | hosts = list(set([i[0] for i in index])) 92 | files = ["/".join([thread_dir, host + ".txt"]) for host in hosts] 93 | for n, i in enumerate(files): 94 | with open(i, "r") as slice: 95 | slice = slice.read().splitlines() 96 | slice = [[hosts[n], *s.split("<>")] for s in slice] 97 | data.append(slice) 98 | data = [x for y in data for x in y] 99 | data.sort(key=lambda x:x[1], reverse=1) 100 | for n, d in enumerate(data): 101 | data[n][2] = unix2atom(d[1]) 102 | data[n].insert(3, f"Reply from {d[0]}") 103 | return data 104 | 105 | def ldcmts(): 106 | # sitename, unixtime, atomtime, title, comment 107 | # site thread number ipaddress time<>name<>comment 108 | with open(s.log, "r") as log: 109 | log = log.read().splitlines()[:-20:-1] 110 | log = [x.split()for x in log] 111 | results = [] 112 | for x in log: 113 | # board thread num ip time author comment 114 | if "" in x[4]: 115 | x[4] += x[5] 116 | x.pop(5) 117 | reply = x[4].split("<>") 118 | try: 119 | int(reply[0]) 120 | except: 121 | continue 122 | reply[2] += " " + " ".join(x[5:]) 123 | url = x[1] + f"#{s.url}/" + x[2] 124 | result = [x[0], url, unix2atom(reply[0]), 125 | f"Reply to {x[0]}/{x[1]}", reply[2]] 126 | 127 | # sitename, unixtime, atomtime, title, comment 128 | results.append(result) 129 | return results 130 | 131 | 132 | def mkatom(title, link, atomloc, index): 133 | feed = {} 134 | if len(index) < 1: 135 | return 136 | feed["title"] = title 137 | feed["url"] = url + link 138 | feed["link"] = url + atomloc 139 | feed["published"] = index[0][2] 140 | body = [] 141 | for i in index: 142 | entry = {} 143 | entry["title"] = i[3] 144 | entry["url"] = "/".join([url, "threads", i[0], i[1]]) 145 | entry["published"] = i[2] 146 | entry["content"] = i[4]\ 147 | .replace("<", "<").replace(">", ">") 148 | body.append(entry_temp.format(**entry)) 149 | head = feed_temp.format(**feed) 150 | atom = "\n".join([head, "\n".join(body), ""]) 151 | return atom 152 | 153 | @atom.route('/atom/global.atom') 154 | def showglobal(): 155 | threads = ldglobal() 156 | _title = "Known network @ " + title 157 | return mkatom(_title, "/threads/", 158 | "/atom/global.atom", threads) 159 | 160 | @atom.route('/atom/.atom') 161 | def showsite(board): 162 | threads = ldsite(board) 163 | _title = " ".join([board, "@", title]) 164 | return mkatom(_title, f"/threads/{board}", 165 | "/atom/{board}.atom", threads) 166 | 167 | @atom.route('/atom/tag/.atom') 168 | def showtag(tag): 169 | threads = ldtag(tag) 170 | _title = " ".join(["Tag", "#"+tag, "@", title]) 171 | return mkatom(_title, f"/tags/{tag}", 172 | "/atom/tag/{tag}.atom", threads) 173 | 174 | @atom.route('/atom//.atom') 175 | def showthread(host, thread): 176 | threads = ldthread(host, thread) 177 | with open("/".join(["./threads", host, thread, "head.txt"]), "r") as head: 178 | head = head.read().splitlines() 179 | _title = head[0] 180 | return mkatom(_title, f"/threads/{host}/{thread}", 181 | "/atom/{host}/{thread}.atom", threads) 182 | 183 | @atom.route('/atom/log.atom') 184 | def showlog(): 185 | posts = ldcmts() 186 | _title = "Last 20 comments @ " + title 187 | return mkatom(_title, "/threads/", "/atom/log.atom", posts) 188 | 189 | 190 | @atom.route('/atom/') 191 | def splash(): 192 | return """ 193 |
    Generate an ATOM feed of the known network:
    194 |   - /atom/global.atom
    195 | 
    196 | Generate an ATOM feed of SITE_NAME (ex: local)
    197 |   - /atom/local.atom
    198 |   - /atom/SITE_NAME.atom
    199 | 
    200 | Generate an ATOM feed of TAG_NAME (ex: random)
    201 |   - /atom/tag/random.atom
    202 |   - /atom/tag/TAG_NAME.atom
    203 | 
    204 | Generate an ATOM feed of THREAD from SITE (ex: local/0)
    205 |   - /atom/local/0.atom
    206 |   - /atom/SITE/THREAD.atom
    207 | 
    208 | Generate an ATOM feed of last 20 comments from local site:
    209 |   - /atom/log.atom
    210 |  
    """ 211 | -------------------------------------------------------------------------------- /refresh.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tags 3 | import settings as s 4 | import utils as u 5 | import mod 6 | 7 | arc = s.archive 8 | friends = s.friends 9 | 10 | def ldsing(host, thread, sub): 11 | error = ["list", "head"] 12 | if sub in error: 13 | return 14 | tdir = "/".join(["./threads", host, thread]) 15 | tf = tdir + "/" + sub + ".txt" 16 | with open(tf, "r") as tf: 17 | tf = tf.read().splitlines() 18 | tf = [x.split("<>")[0] for x in tf] 19 | return tf 20 | 21 | def mkthread(host, thread): 22 | tdir = "/".join(["./threads", host, thread]) 23 | reps = os.listdir(tdir) 24 | reps = [r for r in reps if r.split(".")[0] in s.friends.keys()] 25 | reps = [r.split(".")[0] for r in reps] 26 | reps = [r for r in reps if len(r.strip())] 27 | threads = {} 28 | posts = [] 29 | for r in reps: 30 | threads[r] = ldsing(host, thread, r) 31 | for t in threads: 32 | for p in threads[t]: 33 | posts.append([t, p]) 34 | posts = sorted(posts, key=lambda x: x[1]) 35 | posts = [" ".join(p) for p in posts] 36 | posts = "\n".join(posts) + "\n" 37 | lf = tdir + "/list.txt" 38 | with open(lf, "w") as lf: 39 | lf.write(posts) 40 | 41 | def ldhost(host, write=0): 42 | # Generate new host index based on existing thread indexes. 43 | meta = ["head.txt", "list.txt"] 44 | tdir = "/".join(["./threads", host]) 45 | indpath = "/".join([tdir, meta[1]]) 46 | threads = [x.path for x in os.scandir(tdir) if x.is_dir()] 47 | bind = [] # first, last, local, total, title 48 | for thread in threads: 49 | info = "/".join([thread, meta[0]]) 50 | replies = "/".join([thread, meta[1]]) 51 | if not os.path.isfile(info): 52 | t = thread.split("/")[-1] 53 | orig = "/".join([friends[host], "raw", 54 | "local", t, "head.txt"]) 55 | u.wget(orig, info) 56 | with open(info, "r") as info: 57 | info = info.read().strip() 58 | if len(info) == 0: 59 | continue 60 | info = info.splitlines()[0] 61 | with open(replies, "r") as replies: 62 | replies = replies.read().splitlines() 63 | replies = [r.split(" ") for r in replies] 64 | breps = [r[0] for r in replies] 65 | try: 66 | int(replies[0][1]) 67 | int(replies[-1][1]) 68 | except: 69 | continue 70 | tline = [replies[0][1], replies[-1][1], 71 | str(breps.count("local")), str(len(replies)), 72 | info] 73 | bind.append(tline) 74 | bind.sort(key= lambda x: x[1], reverse=1) 75 | if not write: 76 | return bind 77 | bind = "\n".join([" ".join(t) for t in bind]) 78 | with open(indpath, "w") as ind: 79 | ind.write(bind) 80 | 81 | def mkhost(host): 82 | tdir = "/".join(["./threads", host]) 83 | threads = [x.name for x in os.scandir(tdir) if x.is_dir()] 84 | for thread in threads: 85 | mkthread(host, thread) 86 | ldhost(host, 1) 87 | 88 | def pullhost(host): 89 | f = s.friends 90 | if not host in f: 91 | return 92 | old = arc + host 93 | fn = arc + host + ".new" 94 | url = "/".join([f[host], "raw", "local"]) 95 | index = url + "/list.txt" 96 | newp = [x.split(" ") for x in 97 | u.wget(index, fn).splitlines() 98 | if x.strip()] 99 | if not os.path.exists(old): 100 | oldp = [] 101 | else: 102 | with open(old, "r") as oldp: 103 | oldp = [x.split(" ") for x in 104 | oldp.read().splitlines() 105 | if x.strip()] 106 | diff = [x[0] for x in newp if x not in oldp] 107 | if not diff: 108 | os.remove(fn) 109 | return 110 | for thread in diff: 111 | thread_url = "/".join([url, thread]) 112 | path = "/".join(["./threads", host, thread]) 113 | lurl = thread_url + "/local.txt" 114 | hurl = thread_url + "/head.txt" 115 | lfn = path + "/" + host + ".txt" 116 | hfn = path + "/head.txt" 117 | if not os.path.isdir(path): 118 | os.mkdir(path) 119 | u.wget(lurl, lfn) 120 | u.wget(hurl, hfn) 121 | mkthread(host, thread) 122 | mkhost(host) 123 | os.rename(fn, old) 124 | tags.mkhost(host) 125 | 126 | def mksite(): 127 | fnames = list(friends.keys()) 128 | threads = [] 129 | for f in fnames: 130 | tfn = "/".join(["./threads", f, "list.txt"]) 131 | with open(tfn, "r") as tind: 132 | tind = tind.read().splitlines() 133 | tind = [t.split(" ") for t in tind] 134 | tind = [[f, *t[0:4], " ".join(t[4:])] for t in tind] 135 | threads.append(tind) 136 | threads = sum(threads, []) 137 | threads = sorted(threads, key=lambda x: x[2], reverse=1) 138 | tf = "\n".join([" ".join(t) for t in threads]) 139 | with open("./threads/list.txt", "w") as site: 140 | site.write(tf) 141 | tags.mksite(1) 142 | 143 | def mkfriends(): 144 | fs = [[f, friends[f]] for f in friends.keys()] 145 | f = "\n".join([" ".join(f) for f in fs]) 146 | with open("./threads/friends.txt", "w") as flist: 147 | flist.write(f) 148 | 149 | def linksites(): 150 | mkfriends() 151 | furls = {friends[f]: f for f in friends} 152 | for f in friends: 153 | if f is "local": 154 | continue 155 | 156 | # furl - remote friendslist url 157 | # lurl - remote thread index url 158 | # ffn - friendslist filename (friends.host) 159 | # nffn - new friendslist filename (friends.host.new) 160 | # lfn - thread index filename (list.host) 161 | # nlfn - new thread index filename (list.host.new) 162 | # changes - threads with new replies from self 163 | # hosts - hosts that need their index rewritten 164 | 165 | furl = "/".join([friends[f], "raw", "friends.txt"]) # Legacy: rename /raw/ -> /api/ 166 | lurl = "/".join([friends[f], "raw", "list.txt"]) 167 | ffn = arc + "friends." + f 168 | if not os.path.exists(ffn): 169 | with open(ffn, "w") as fi: 170 | fi.write("") 171 | nffn = ffn + ".new" 172 | lfn = arc + "list." + f 173 | if not os.path.exists(lfn): 174 | with open(lfn, "w") as fi: 175 | fi.write("") 176 | nlfn = lfn + ".new" 177 | u.wget(furl, nffn) 178 | u.wget(lurl, nlfn) 179 | 180 | # Ideally, a list of [name, op] localreplies 181 | # is compared against the older version, and 182 | # if a difference is found, {common}/{thread}/{friend} 183 | # is downloaded, {common}/{thread} & {common} are then 184 | # rebuilt. This is contingent on {common} being a common 185 | # host between client and server. 186 | with open(nffn, "r") as nf: 187 | nf = [x.split() for x in nf.read().splitlines()] 188 | if len(nf) < 1: 189 | continue 190 | if len(nf[0][1]) < 6: 191 | continue 192 | # This breaks if a friend URL is blank 193 | nfurls = {x[1]: x[0] for x in nf if len(x) > 1} 194 | common = {nfurls[x]: furls[x] for x in nfurls if x in furls} 195 | common2 = {common[f]: f for f in common} 196 | with open(lfn, "r") as oldl: 197 | oldl = [o.split() for o in oldl.read().splitlines()] 198 | with open(nlfn, "r") as newl: 199 | newl = [n.split() for n in newl.read().splitlines()] 200 | changes = [] 201 | for n in newl: 202 | if n[0] not in common.keys(): 203 | continue 204 | if not int(n[3]): 205 | continue 206 | if n in oldl: 207 | continue 208 | n = [common[n[0]], n[1], n[3]] 209 | changes.append(n) 210 | 211 | for c in changes: 212 | url = "/".join([friends[f], "raw", common2[c[0]], 213 | c[1], "local.txt"]) 214 | ldir = "/".join(["./threads", c[0], c[1]]) 215 | local = "/".join([ldir, f + ".txt"]) 216 | if not os.path.isdir(ldir): 217 | os.mkdir(ldir) 218 | u.wget(url, local) 219 | mkthread(c[0], c[1]) 220 | hosts = set([c[0] for c in changes]) 221 | for b in hosts: 222 | mkhost(b) 223 | os.rename(nffn, ffn) 224 | os.rename(nlfn, lfn) 225 | mksite() 226 | 227 | 228 | def main(): 229 | for f in friends: 230 | if f == "local": 231 | mkhost("local") 232 | continue 233 | if not os.path.isdir("./threads/" + f): 234 | os.mkdir("./threads/" + f) 235 | pullhost(f) 236 | mksite() 237 | linksites() 238 | mod.main() 239 | 240 | if __name__ == "__main__": 241 | main() 242 | 243 | # mkthread(host, thread) 244 | # modifies thread's list 245 | # mkhost(host) 246 | # makes host/list.txt from mkhost in all subdirs 247 | # mksite() 248 | # makes /list.txt from all host/list.txt 249 | 250 | -------------------------------------------------------------------------------- /viewer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import re 4 | from flask import Blueprint 5 | from flask import request 6 | import utils as u 7 | import settings as s 8 | import pagemaker as p 9 | import writer 10 | import whitelist 11 | 12 | viewer = Blueprint("viewer", __name__) 13 | friends = s.friends 14 | 15 | with open("templ/post.t", "r") as postt: 16 | postt = postt.read() 17 | with open("templ/newr.t", "r") as newr: 18 | newr = newr.read() 19 | with open("templ/thread.t", "r") as threadt: 20 | threadt = threadt.read() 21 | 22 | def hostlist(li=0): 23 | hosts = [x.path.split("/")[2]for x 24 | in os.scandir("./threads/") if x.is_dir()] 25 | if li == 1: 26 | return hosts 27 | hosts2 = hosts 28 | hosts.remove("local") 29 | hosts.insert(0, "local") 30 | hosts =[] 31 | for x in hosts2: 32 | hosts.append(f"\n{x}") 33 | hosts.insert(0, "\nGlobal") 34 | hosts = "\nHosts: " + " \n♦ ".join(hosts) 35 | hosts = "
    " + hosts + "\n
    " 36 | return hosts 37 | 38 | def tlist(host=''): 39 | linkf = "{1}{2}" 40 | linkl = [] 41 | if not host: 42 | all_index() 43 | if host not in s.friends: 44 | all_index() 45 | # if host and host in s.friends: 46 | with open(f"./threads/{host}/list.txt", "r") as toplist: 47 | toplist = toplist.read().splitlines() 48 | for t in toplist: 49 | t = t.split(" ") 50 | t[4] = " ".join(t[4:]) 51 | t[0] = f"/threads/{host}/{t[0]}/" 52 | linkl.append(linkf.format(t[0], t[4], t[3])) 53 | return linkl 54 | 55 | def all_index(): 56 | linkf = "{3} {1} {2} " 57 | linkl = [] 58 | blist = hostlist(1) 59 | toplist = [] 60 | for b in blist: 61 | with open(f"./threads/{b}/list.txt", "r") as t: 62 | t = t.read().splitlines() 63 | toplist.append([" ".join([x, b]) for x in t]) 64 | toplist = [y for x in toplist for y in x] 65 | toplist = [x.split(" ") for x in toplist] 66 | toplist.sort(key=lambda x:x[1], reverse=1) 67 | toplist = [" ".join(x) for x in toplist] 68 | for t in toplist: 69 | t = t.split(" ") 70 | t[4] = " ".join(t[4:-1]) 71 | t[0] = f"/threads/{t[-1]}/{t[0]}/" 72 | if t[-1] == "local": 73 | pass 74 | else: 75 | t[-1] = f"{t[-1]}" 76 | linkl.append(linkf.format(t[0], t[4], t[3], t[-1])) 77 | return linkl 78 | 79 | @viewer.route('/threads/') 80 | def view_all(): 81 | tops = all_index() 82 | tops[0] = f"

    ({len(tops)} discussions) ♦ " \ 83 | + "Add new
    " \ 84 | + "

    All Sites

    " \ 85 | + "" + tops[0] 87 | page = p.mk(hostlist() + "".join(tops) + "
    origintitlereplies" \ 86 | + "
    ") 88 | return page 89 | 90 | @viewer.route('/threads//') 91 | def view(host): 92 | # tlist() takes host input 93 | if host and host in s.friends: 94 | url = s.friends[host] 95 | tops = tlist(host) 96 | tops[0] = f"
    ({len(tops)} discussions) ♦ " \ 97 | + "Add new ♦ " \ 98 | + f"from {url}
    " \ 99 | + f"

    {host}

    " \ 100 | + "" + tops[0] 102 | else: 103 | tops = tlist() 104 | tops[0] = "

    All Sites

    1. " + tops[0] 105 | if host == "sageru": 106 | tops[0] = u.bees + tops[0] 107 | tops[0] = hostlist() + tops[0] 108 | return p.mk("
    ".join(tops) + "
    titlereplies" \ 101 | + "
    \n") 109 | 110 | #@viewer.route('/threads///') 111 | def view_t(host, thread): 112 | lock = 0 113 | tpath = f"./threads/{host}/{thread}/" 114 | if os.path.isfile(tpath+"lock"): 115 | lock = 1 116 | tinfo = {"title":"", "source":"", "tags":"", "messages":""} 117 | # Get the list of thread replies and the thread title. 118 | with open(tpath + "list.txt", "r") as tind: 119 | thr = [t.split(" ") for t in tind.read().splitlines()] 120 | with open(tpath + "head.txt", "r") as meta: 121 | meta = meta.read().splitlines() 122 | tlink = "#{0}" 123 | meta[1] = meta[1].split(" ") 124 | meta[1] = " ".join([tlink.format(m) for m in meta[1]]) 125 | tinfo["tags"] = meta[1] 126 | # meta[1] = "tags: " + meta[1] 127 | 128 | # Load the replies. 129 | hosts = set([t[0] for t in thr]) 130 | tdb = {} 131 | for b in hosts: 132 | bfn = tpath + b + ".txt" 133 | with open(bfn, "r") as bfn: 134 | bfn = bfn.read().splitlines() 135 | for n, x in enumerate(bfn): 136 | x = x.split("<>") 137 | if s.ihost in x[2]: 138 | try: 139 | x[2] = u.imgur(x[2]) 140 | except: 141 | continue 142 | tdb[x[0]] = [b, *x, n] 143 | 144 | threadp = [] 145 | pnum = 0 146 | psub = 0 147 | cnt = {friends[x]: 0 for x in hosts} 148 | for p in sorted(tdb.keys()): 149 | p = tdb[p] 150 | p.append(p[0]) 151 | p[4], p[5] = p[5], p[4] 152 | aname = friends[p[0]] 153 | if p[0] == host: 154 | pnum += 1 155 | psub = 0 156 | p[0] = f"#{str(pnum)}" 158 | else: 159 | psub += 1 160 | cnt[aname] += 1 161 | p[0] = ",".join([str(pnum), str(psub)]) 162 | p[0] = f"#{p[0]}" 164 | if p[4] != "local": 165 | p[4] = f"🌎 {p[4]}" 166 | else: 167 | p[4] = "" 168 | p[3] = p[3].split("
    ") 169 | p[3] = "
    ".join([f"{x}" 170 | if len(x) and x[0] == ">" else x 171 | for x in p[3]]) 172 | p[1] = u.unix2hum(p[1]) 173 | p[3] = p[3].replace("&", "&") 174 | 175 | # Set up >>linking 176 | fquote = {">>" + friends[f]: f for f in friends} 177 | replies = [] 178 | for f in fquote: 179 | if f in p[3]: 180 | p[3] = p[3].split(f) 181 | for n, x in enumerate(p[3]): 182 | if ">https://" + r.split("/")[2] 193 | elif "http" in r: 194 | r2 = ">>http://" + r.split("/")[2] 195 | r2 = r.replace(r2, ">>" + fquote[r2]) 196 | except: 197 | r2 = r 198 | rep = "" \ 199 | + r2 + "" 200 | p[3] = p[3].replace(r, rep) 201 | 202 | if re.compile(r'>>[\d]').search(p[3]): 203 | p[3] = re.sub(r'>>([\d\,]+)([\s]?)<', 204 | r'>>\1<', 205 | p[3]) 206 | 207 | p = postt.format(*p) 208 | threadp.append(p) 209 | tinfo["messages"] = "".join(threadp) 210 | tinfo["title"] = meta[0] 211 | if lock: 212 | tinfo["title"] = "🔒 " + tinfo["title"] 213 | # threadp.insert(0, f"

    {meta[0]}

    ") 214 | # threadp[0] += "source: " 215 | if host != "local": 216 | tinfo["source"] += "🌎" 217 | tinfo["source"] += "{0}".format(host) 218 | thread = threadt.format(tinfo["title"], tinfo["source"], tinfo["tags"], 219 | tinfo["messages"]) 220 | if host == "sageru": 221 | thread = u.bees + thread 222 | return thread 223 | 224 | @viewer.route('/threads///', methods=['POST', 'GET']) 225 | def reply_t(host, thread): 226 | now = str(int(time.time())) 227 | if request.method == 'POST': 228 | if request.form['sub'] == "Reply": 229 | author = request.form["author"] or "Anonymous" 230 | message = request.form["message"] 231 | if not message: 232 | return "please write a message" 233 | if not whitelist.approve(): 234 | return "please solve the captcha" 235 | tpath = "/".join(["./threads", host, thread, "local.txt"]) 236 | flood = whitelist.flood(s.post) 237 | if flood: return flood 238 | writer.rep_t(host, thread, now, author, message) 239 | writer.update_host(host, thread, now) 240 | redir = f"/threads/{host}/{thread}" 241 | return f"

    View updated thread

    " 242 | tpage = view_t(host, thread) 243 | canpost = whitelist.approve() 244 | lock = 0 245 | if os.path.isfile(f"./threads/{host}/{thread}/lock"): 246 | lock = 1 247 | if not canpost: 248 | replf = whitelist.show_captcha(1, f"/threads/{host}/{thread}/") 249 | elif lock: 250 | replf = "
    🔒 Thread has been locked. No more comments are allowed.
    " 251 | else: 252 | replf = newr.format(host, thread) 253 | tpage += replf 254 | return p.mk(str(tpage)) 255 | -------------------------------------------------------------------------------- /_dox/About.html: -------------------------------------------------------------------------------- 1 | Multichan - why, about, how 2 | 3 | 16 | 17 |
      18 |
    • What is Multichan? 19 |
    • Textboard software 20 |
    • Tags' superiority to ``boards" 21 |
    • Why Federation is the future 22 |
    • Influences 23 |
    24 | 25 |

    What is Multichan?

    26 | 27 | Multichan is a federated, tag-based textboard server. 28 | It is public domain software available here:
    29 |     30 | https://bitbucket.org/796f/multichan 31 |
    meaning you can download the software, modify it any way you want, 32 | and share it or run it however you wish. You can view a live version 33 | of multichan at https://0chan.vip . 34 |

    35 | The name is a reference to 2channel, named originally for being the 36 | ``second channel'' of a popular anonymous textboard in Japan. It was the 37 | successor to a site called Amezou, which was a popular anonymous 38 | messageboard. People could use their computers to share ideas and 39 | communicate completely independently of their offline identity. This 40 | resulted in Amezou becoming very popular; 2channel was meant to help 41 | support the Amezou by remaining online when the other site ran out of 42 | bandwidth or otherwise went offline. It grew to become the largest web 43 | bulliten board for many years. 44 |

    45 | Multichan extends on this idea of a plurality of servers by employing 46 | federation, where all servers can back each other up, creating an system 47 | of networked discussions. A Multichan server will faithfully copy 48 | discussions and responses from other Multichan servers which are in its 49 | friends list. In this way, the network can be understood as simply a 50 | collection of messages from users, while various websites offer 51 | different views of this network based on the personal biases of 52 | independent server operators. A server can offer either a very open 53 | view of the community, or a more limited one; this experiment will 54 | yield interesting results. 55 | 56 | 57 |

     58 |      ,--- 0chan.vip , bbs.4x13.net , etc
     59 |      |
     60 |      v            v--- Web browser, app, etc 
     61 |   Server <--> Client
     62 | 

    63 | 64 | Clients can create a discussion topic, reply to a discussion, view a 65 | discussion, or get a list of discussions. Discussions can be assigned a 66 | tag. A list of tags, or a list of threads under a tag, or a list of 67 | threads from multiple tags can be viewed. 68 |

    69 | Servers share discussions with each other, too. So, what does this all 70 | mean? Discussions can be held in a server-agnostic fashion. The 71 | server-agnostic nature of a discussion means that unpopular moderation 72 | decisions will possibly punish a server by reducing its userbase; 73 | in time, servers with the least amount of moderation will become the 74 | most popular, followed by ones that filter out just spam and trolling, 75 | followed by ones that restrict the tag list / heavily filter new threads 76 | and responses, etc. 77 |

    78 | The multichan server can also be used to make your own personal backups 79 | of the network, even if you don't plan to host a website yourself. In 80 | the future, more tools will be released to do things with this database. 81 | 82 |

    Textboard software

    83 | 84 | Defining characteristics of textboard software can best be understood by 85 | comparing them with ``typical" forums. 86 |

    87 | Textboards:

      88 |
    • Do not require registration. 89 |
      Most web messageboards require, at the very least, a username, 90 | email address, and a password in order to make posts. A downside 91 | of this is that registration is generally annoying, and it can 92 | constrain a user's freedom to share thoughts because account-based 93 | systems lead to the development of personas. The value inherent to 94 | a position can be overlooked when the reputation of the speaker is 95 | necessarily attached. Binding messages to their poster's identity 96 | can also lead people to play games where personas are pitted against 97 | each other or become the focus of discussion themselves, which does 98 | not generally enhance conversations. Finally, registration can be a 99 | security risk -- emails or passwords can be leaked, or usernames 100 | can become the object of cyber-stalking. 101 |
    • Focus on text, rather than images or video. 102 |
      If the purpose of a messageboard can be stated simply, it is to 103 | provide a space for conversations to take place. Sometimes, 104 | embedding images can clarify a message. At other times, they are 105 | used to stop dialog or provide no value to the conversation, 106 | especially in the form of memes. In some mediums (such as Facebook, 107 | Twitter) recycled images and video substitute discussion entirely. 108 | There is little incentive to post time-wasting / irrelevant media 109 | in a text-only forum. Indeed, better software exists for social 110 | media sharing -- Danbooru, Pleroma, etc. 111 |
    • Sort conversations by active participation. 112 |
      For websites that focus on breaking news or new multimedia, 113 | recent activity is not a good sort metric. But for general 114 | discussion-based websites, it does fine. Novelty is not the 115 | defining quality of most discussion topics; indeed, a conversation 116 | should be able to continue for days, weeks, or months, if it 117 | continues to be relevant to people. 118 |
    • Do not have expiration dates for discussion topics. 119 |
      In line with the previous point, conversations on textboards 120 | generally take place over days, weeks, or even years. This is in 121 | contrast especially with imageboards (on 4chan, conversations 122 | generally do not persist for over 24 hours) but also Reddit, 123 | Facebook, or even Twitter, where the focus is on discussions 124 | started recently, generally within the last week. 125 |
    • Bore boring people. 126 |
      Because textboards are generally adverse to low-effort 127 | contributions (especially media recycling) and encourage 128 | anonymity, there is little incentive for boring people to stick 129 | around a textboard. 130 |
    • Are easy to setup and modify. 131 |
      The majority of textboard software rejects the use of SQL servers, 132 | favoring flatfile databases. Because the read and write operations 133 | on the data is simple, and the data itself is simple, extending or 134 | altering basic functions becomes very easy. All that's historically 135 | been needed to run a textboard is a domain name and CGI-capable 136 | server. Multichan simplifies the installation process further by 137 | including its own webserver, which makes setup as simple as 138 | downloading the software and then running the included script. 139 |
    140 | 141 |

    Tags' superiority to ``boards"

    142 | 143 | For over 20 years, 2ch (and in turn 4chan, 8chan, etc) have depended on 144 | the concept of a board to organize threads. These are based on the idea 145 | of a newsgroup, which is over 35 years old (stemming from USENET). 2ch's 146 | system of boards essentially categorize every conversation under a single 147 | label, such as breaking news, psychology, soft drinks, childcare, Trump, 148 | sumo, etc etc. The shift from newsgroups to boards is a downside in that 149 | USENET groups offer a clear hierarchy: eg, alt.tv.talkshows.daytime 150 | is more specific than alt.tv.talkshows which is more specific 151 | than alt.tv. 152 |

    153 | 2ch tried to address the limitation of a ``board" (simple index of 154 | conversations) by simply creating as many boards as possible. There are 155 | several hundred 2ch boards in existence today. Essentially running 156 | hundreds of isolated websites for one community is not pragmatic for 157 | administration; for readers and commenters, it's only ideal if the user 158 | is interested in a limited number of topics. 159 |

    160 | When one or more topics apply to a conversation, there are two simple 161 | remedies on simple board based websites. One is cross-posting: the 162 | same conversation is copied to (ex) 3 or 4 places, then 3 or 4 different 163 | conversations are taking place based on the same topic message. The 164 | second is multi-board browsing (ex) multiple boards have their 165 | conversations pooled together into one meta-board. This is problematic 166 | because it still leads to duplicate threads as a result of cross-posting 167 | (repeating the same thread in multiple boards to make it visible to more 168 | people) The only real solution to actually implement tagging, which 169 | means that the same exact conversations exist in multiple places. This 170 | best serves the purpose of a board or directory in the first place: 171 | narrowing the global index of conversations based on a theme. 172 |

    173 | 2ch and 4chan, unlike 8chan, never added the ability to make new boards, 174 | or to view multiple boards at the same time. Multichan goes a step 175 | further than 8chan by eliminating boards while making a vast number of 176 | tags available. A topic with multiple categories will still be listed in 177 | these multiple indexes, but if they're viewed together, the topic will 178 | only be listed once, as it should. 179 | 180 |

    Why Federation is the future

    181 | 2ch and 4chan have traditionally viewed other servers in the anonymous 182 | board network with hostility; this anti-social attitude unfortunately 183 | means that their staff members have to manage too many boards and too 184 | many users, instead of spreading responsibility. To see how to community 185 | tries to address this limitation, let's look at imageboard 186 | directories. 187 | This one (allchans.org) lists about 30 imageboard servers, each of 188 | which hosts 10-50 boards on average. 189 |

    190 | iichan was the first board that tried to address the problem of running 191 | many boards: different site owners volunteered to run some boards, and 192 | all sites would link to each other, creating a network with many boards 193 | operated independently by multiple server operators.
    194 | Lynxchan/Vichan are starting to address the problem in a sophisticated 195 | way by sorting links across the network to other boards by info like 196 | ``posts per hour", users, and last activity. 197 |

    198 | NNTPchan realized that sharing threads between servers solves one 199 | problem of imageboards: the same board exists in many places, but each 200 | board only exists in one; this fractures the userbase. With NNTPchan, 201 | the more servers that exist, the stronger and more unified the network 202 | is. 203 |

    204 | Archive boards and ghost boards, such as 205 | warosu.org add more nuance to how text and imageboards on one site 206 | respond to those on others. Warosu (fuuka) not only creates a 1:1 207 | duplication of certain 4chan conversations; it also provides its own 208 | commenting system for its users to make replies on 4chan conversations 209 | from Warosu's servers; the creation of Warosu was primarily a response 210 | to 4chan's overzealous post-removing philosophy. Warosu makes all 211 | comments available, while users decide for themselves what they want to 212 | see. This is the archive board. The ghost board is the comments from 213 | Warosu users themselves, which 4chan never sees. 214 |

    215 | Once meta-board and meta-site browsing is common, along with board 216 | archival, federation becomes the next obvious step. While overboards, 217 | webrings, and archives try to promote equality between servers and 218 | boards, proper federation goes a step further by eliminating the 219 | difference. Federation links and archives boards by acknowledging 220 | that remote archives will receive comments that may be relevant to the 221 | original discussion at hand. 222 | 223 |

    Influences

    224 | 225 | The influences upon multichan are numerous: 226 |
      227 |
    • 2channel -- the original anonymous 228 | textboard website. 229 |
    • 1chan.us -- uses tags, rather than 230 | boards, to organize threads. 231 |
    • Federated 232 | Wiki -- the creator of the wiki came to believe federation 233 | could solve the problems of users and administrators alike by 234 | overcoming the limitations imposed by any single server / central 235 | authority. 236 |
    • nntpchan -- 237 | decentralized (federated) imageboard software. Formerly known as 238 | overchan. 239 |
    240 |
    241 | last updated 2021-06-09 242 | -------------------------------------------------------------------------------- /boards.py: -------------------------------------------------------------------------------- 1 | import time 2 | import os 3 | from flask import Blueprint, request 4 | import pagemaker as p 5 | import viewer as v 6 | import tags as t 7 | import settings as s 8 | import utils as u 9 | import tripcode as tr 10 | import whitelist 11 | 12 | 13 | boards = Blueprint("boards", __name__) 14 | index_p = "./boards/list.txt" 15 | with open(index_p, "r") as index: 16 | index = index.read().splitlines() 17 | local_b = [i.split(" ") for i in index] 18 | 19 | # /b/ 20 | # splash() 21 | # /b/board/ 22 | # browse(board) 23 | # /b/board/key 24 | # mod_board(board, key) 25 | # /b/board/host/thread 26 | # show_thread(board, host, thread) 27 | # # board_index(board) 28 | # # load_thread(board, host, thread) 29 | 30 | def board_index(board): 31 | # mod.txt 32 | # 0 - hide 33 | # 1 - normal 34 | # 2 - lock 35 | # 3 - sticky 36 | # 4 - lock sticky 37 | # 5 - permasage 38 | 39 | threads = t.tags_threads([board]) # [host, thread] 40 | with open("./threads/list.txt", "r") as info: 41 | info = info.read().splitlines() # [host, thread, last, Lrep, Grep, title] 42 | info = [i.split(" ") for i in info] 43 | info = [i for i in info if i[:2] in threads] 44 | info = [[*i[:5], " ".join(i[5:])] for i in info] 45 | 46 | mfile = f"./boards/{board}/threads.txt" 47 | to_hide = [] # 0 48 | to_sticky = [] # 2 49 | to_sage = [] # 3 50 | 51 | hidden = [] 52 | normal = [] 53 | sages = [] 54 | stickies = [] 55 | 56 | with open(mfile, "r") as mod: 57 | mod = mod.read().splitlines() 58 | for n, m in enumerate(mod): 59 | entry = m.split(" @ ") 60 | entry[0] = entry[0].split(" ") 61 | try: 62 | if int(entry[1][0]) == 0: # hidden 63 | to_hide.append(entry[0]) 64 | elif int(entry[1][0]) == 2: # sticky 65 | to_sticky.append(entry[0]) 66 | elif int(entry[1][0]) == 3: # sage 67 | to_sage.append(entry[0]) 68 | else: 69 | normal.append(entry[0]) 70 | except: 71 | pass 72 | 73 | link = "
  • {1} ({2} replies)" 74 | sticky = "
  • 📌 {1} ({2} replies)" 75 | sage = "
  • {1} ({2} replies)" 76 | 77 | for n, entry in enumerate(info): 78 | item = [entry[0], entry[1]] 79 | url = "/".join([f"/b/{board}", *item]) 80 | link_data = [url, entry[5], entry[4]] 81 | if item in to_sticky: 82 | stickies.append(sticky.format(*link_data)) 83 | elif item in to_sage: 84 | sages.append(sage.format(*link_data)) 85 | elif item in to_hide: 86 | hidden.append(link.format(*link_data)) 87 | else: 88 | normal.append(link.format(*link_data)) 89 | 90 | links = [] 91 | links += stickies 92 | links += normal 93 | links += sages 94 | return links 95 | 96 | @boards.route('/b/') 97 | def splash(): 98 | with open(index_p, "r") as index: 99 | index = index.read().splitlines() 100 | local_b = [i.split(" ") for i in index] 101 | boards = local_b 102 | template = "
  • {0} (managed by {1})" 103 | page = """

    User Boards

    104 | Boards are a work in progress system that will allow user-managed 105 | communities to exist within the Multichan network. 106 |
    Register a board by visiting /b/tagname/password , where 107 | tagname is a tag that exists and password 108 | is a secret password. HTML can be used in intro.txt ; 109 | moderating threads is done by adding new entries to 110 | threads.txt and moderating comments is done by adding new 111 | entries to hide.txt . One entry per line. 112 |

    """ 113 | page += "

      " \ 114 | + "\n".join([template.format(*i) for i in boards]) \ 115 | + "
    " 116 | return p.mk(page) 117 | 118 | @boards.route('/b//', methods=['POST', 'GET']) 119 | @boards.route('/b//list') 120 | def browse(board): 121 | if request.method == 'POST': 122 | if not whitelist.approve(): 123 | return p.mk(whitelist.show_captcha(1)) 124 | user_key = tr.sec(request.form["key"]) 125 | with open(index_p, "r") as index: 126 | index = index.read().splitlines() 127 | local_b = [i.split(" ") for i in index] 128 | test = [i for i in local_b if (i[0] == board and i[1] == user_key)] 129 | if not len(test): 130 | pass 131 | files = ["info.txt", "hide.txt", "threads.txt", "ihosts.txt"] 132 | try: 133 | for f in files: 134 | path = "./boards/" + board + "/" 135 | data = request.form[f].strip() 136 | with open(path+f, "w") as out: 137 | out.write(data) 138 | 139 | except: 140 | print(board) 141 | 142 | 143 | info = f"./boards/{board}/info.txt" 144 | with open(info, "r") as about: 145 | about = about.read() 146 | page = ["
    "] 147 | page.append(f"[back]") 148 | page.append(f"

    /{board}/

    ") 149 | page.append(f"") 150 | page.append(about) 151 | page.append(f"


    [+] Create a new thread on /{board}/") 152 | with open(f"./boards/{board}/ihosts.txt", "r") as ihosts: 153 | ihosts = ihosts.read().strip().splitlines() 154 | page.append("
    Image hosts: " + " ♥ ".join(ihosts)) 155 | page.append("
      ") 156 | threads = board_index(board) 157 | # threads = "\n".join(threads) 158 | page += threads 159 | page.append("
    ") 160 | page = "\n".join([n for n in page if (len(n) != 2)]) 161 | return p.mk(page) 162 | 163 | def mkboard(board, key): 164 | if not whitelist.approve(): 165 | return p.mk(whitelist.show_captcha(1)) 166 | key = tr.sec(key) 167 | path = "./boards/" + board + "/" 168 | try: 169 | os.mkdir(path) 170 | except: 171 | pass 172 | with open("./boards/list.txt", "a") as li: 173 | li.write(f"{board} {key}\n") 174 | try: 175 | os.mkdir(path) 176 | except: 177 | pass 178 | files = ["info.txt", "threads.txt", "hide.txt", "ihosts.txt"] 179 | for f in files: 180 | _path = path + f 181 | with open(_path, "w") as fi: 182 | if f == "ihosts.txt": 183 | fi.write(s.ihost) 184 | else: 185 | fi.write("") 186 | 187 | @boards.route('/b//') 188 | def mod_board(board, key): 189 | new_local = [] 190 | if not whitelist.approve(): 191 | return p.mk(whitelist.show_captcha(1)) 192 | with open(index_p, "r") as index: 193 | index = index.read().splitlines() 194 | local_b = [i.split(" ") for i in index] 195 | bs = [x[0] for x in local_b] 196 | if board not in bs: 197 | mkboard(board, key) 198 | return str([board, key]) 199 | for L in local_b: 200 | if tr.sec(key) == L[1] and L[0] == board: 201 | new_local.append(L) 202 | if not len(new_local): 203 | return "0" 204 | page = [""] 205 | page.append("
    ") 206 | page.append("") 207 | page.append(f"") 208 | page.append(f"") 209 | mod = {} 210 | files = ["info.txt", "threads.txt", "hide.txt", "ihosts.txt"] 211 | for f in files: 212 | with open(f"./boards/{board}/{f}", "r") as data: 213 | data = data.read().strip() 214 | mod[f] = data + "\n" 215 | for f in mod: 216 | page.append(f) 217 | if f == "threads.txt": 218 | page[-1] += " // format: host thread @ mode ; 0 hide ; 2 sticky; 3 sage" 219 | elif f == "hide.txt": 220 | page[-1] += " // format: host thread host reply# " 221 | page.append(f"") 222 | return "
    " + "\n".join(page) + "
    " 223 | 224 | def load_thread(board, host, thread): 225 | # Board view of host:thread 226 | 227 | # path = [] # [[host, time]] 228 | posts = {} # {"host": [time, time, time]} 229 | data = {} # {"host": [[host, time, author, message]] } 230 | _thread = [] # [[host, time, author, message]] 231 | 232 | origin = [host, thread] # 0chan, 0 233 | 234 | path = f"./threads/{host}/{thread}/" 235 | hide = f"./boards/{board}/hide.txt" 236 | ihost = f"./boards/{board}/ihosts.txt" 237 | 238 | with open(path + "list.txt", "r") as path: 239 | path = path.read().splitlines() 240 | path = [p.split(" ") for p in path] 241 | with open(ihost, "r") as ihosts: 242 | ihosts = ihosts.read().splitlines() 243 | ihosts = [i.strip() for i in ihosts] 244 | with open(hide, "r") as hide: 245 | hide = hide.read().splitlines() 246 | hide = [h.split(" ") for h in hide] 247 | hide = [h for h in hide if h[:2] == origin] 248 | for p in path: 249 | # site time 250 | if p[0] not in posts.keys(): 251 | posts[p[0]] = [] 252 | posts[p[0]].append(p[1]) 253 | 254 | # posts[host][reply, reply, reply] 255 | data = posts.copy() 256 | for d in data: 257 | whereis = f"./threads/{host}/{thread}/{d}.txt" 258 | with open(whereis, "r") as files: 259 | files = files.read().splitlines() 260 | files = [[d, *f.split("<>")] for f in files] 261 | data[d] = files 262 | # data[host][rep] = [post, goes, here] 263 | # {"host": ["host", "time", "author", "message"]} 264 | 265 | for h in hide: 266 | if [host, thread] != [h[0], h[1]]: 267 | continue 268 | data[h[2]][int(h[3])-1] = [data[h[2]][int(h[3])-1][0], 269 | data[h[2]][int(h[3])-1][1], 270 | "deleted", 271 | "message deleted by admin"] 272 | 273 | cnt = {} 274 | for p in path: 275 | h = p[0] 276 | if h not in cnt: 277 | cnt[h] = 0 278 | _thread.append(data[h][cnt[h]]) 279 | cnt[h] += 1 280 | return _thread 281 | # data["host"][cnt] = ["host", "time", "author", "message"] 282 | # _thread = list.txt x ["host", "time", "author", "message"] 283 | 284 | @boards.route('/b////') 285 | def show_thread(board, host, thread, methods=['POST', 'GET']): 286 | # datetime = "%a, %b %d, %Y, @ %-I %p" 287 | # datetime = time.strftime(datetime, time.localtime(int(x[1]))), 288 | test = mod_board(board, host) 289 | tindex = load_thread(board, host, thread) # [[host, time, author, comment]] 290 | tindex = [[x[0], u.unix2hum(x[1]), *x[2:]] for x in tindex] 291 | head = f"./threads/{host}/{thread}/head.txt" 292 | with open(head, "r") as head: 293 | head = head.read().splitlines()[0] 294 | page = ["
    "] 295 | page.append(f"{s.name} /{board}/") 296 | page.append(f"

    {head}

    ") 297 | cnt = {} 298 | render = [] 299 | with open("./templ/post.t", "r") as reply: 300 | reply = reply.read() 301 | for t in tindex: 302 | with open(f"./boards/{board}/ihosts.txt", "r") as ihosts: 303 | ihosts = ihosts.read().splitlines() 304 | 305 | if t[0] not in cnt: 306 | cnt[t[0]] = 0 307 | cnt[t[0]] += 1 308 | b = [t[0], s.friends[t[0]]] 309 | ref = f"{b[1]}/{cnt[b[0]]}" 310 | link = f"" 312 | link += f">>{b[0]}/{cnt[b[0]]}" 313 | 314 | for i in ihosts: 315 | print([i, t[3]]) 316 | if i in t[3]: 317 | t[3] = u.imgur(t[3], i) 318 | break 319 | t[3] = t[3].split("
    ") 320 | t[3] = "
    ".join([f"{x}" 321 | if len(x) and x[0] == ">" else x 322 | for x in t[3]]) 323 | 324 | # 0 reply, # 1 date, #2 name, #3 comment, #4 host 325 | # 0 host, # 1 time, #2 author, #3 comment 326 | page.append(reply.format(link, t[1], t[2], t[3], "")) 327 | canpost = whitelist.approve() 328 | with open("./templ/newr.t", "r") as newr: 329 | newr = newr.read().format(host, thread) 330 | if not canpost: 331 | replf = whitelist.show_captcha(1, f"/threads/{host}/{thread}/") 332 | else: 333 | replf = newr.format(board, thread) 334 | page.append(replf) 335 | page.append("
    ") 336 | return p.mk("
    ".join(page)) 337 | 338 | @boards.route('/b//0') 339 | def front(board): 340 | with open(f"./threads/{board}/list.txt", "r") as index: 341 | index = index.read().splitlines() 342 | --------------------------------------------------------------------------------