commit 5af448bbd6ec98a990f1137d25fb78fca82b155a Author: pezcurrel Date: Sat Nov 18 21:55:08 2023 +0100 First commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddbd507 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +## What is this? + +Unocck is a web tool that allows to easily check if a Mastodon or Mastodon compatible instance is censoring a given ActivityPub post. + +## Setting up Unocck on a webserver + +To set up Unocck on a webserver you just need it to support PHP, to make the “sec” directory writeable by the webserver’s user and to put into it a “conf.ini” file like this: + +``` +host=[hostname of the instance to check] +hostdesc=[a terse description of the instance, in html] +maintref=[a terse reference to you, in html] +token=[the access token of an application that you have already set up on an account on the instance defined by “host” (in Mastodon web UI you can set up an application under “Preferences” -> “Development”; the only privilege it needs is “read:search”)] +``` + +You can optionally also set a “footer” that will be added before the link to this repo in page footer, and a proxy to use for connections; for example: + +``` +footer=Home +proxy=tcp://localhost:8118 +``` + +You can change Unocck icons and its “OG Image” by editing files inside the “imgs” directory. + +## Are there running Unocck instances? + +[Here](https://mastodon.help/unocck/) you can find a running Unocck instance checking mastodon.uno, a Mastodon instance whose admins are well known for [bad practices](https://qua.name/diorama/astroturfing-the-italophone-fediverse) and censoring those who criticize them. + +If you set up your own and you want it to be listed here, please let me know using the e-mail address you can find in my profile page. diff --git a/css/main.css b/css/main.css new file mode 100644 index 0000000..4a8aca9 --- /dev/null +++ b/css/main.css @@ -0,0 +1,233 @@ +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: "sans"; + background-color: #222222; + color: white; + margin: 0; + padding: 0; +} + +a { + /*color: #87decd;*/ + color: lightgreen; +} + +form { + padding: 0; + margin: 0; +} + +h1, h2, h3, h4, h5, h6 { + text-align: center; + color: white; +} + +p { + margin: 0; + color: white; + text-indent: 3mm; + /*text-align: justify; + -webkit-hyphens: auto; + -ms-hyphens: auto; + hyphens: auto;*/ +} + +.firstp { + text-indent: 0; +} + +input:focus, textarea:focus, button:focus { + outline: none; +} + +#main { + margin-left: auto; + margin-right: auto; + max-width: 20cm; + width: 100%; + padding: 3mm; +} + +.error, .success, .warning, .neutral, .normtext { + width: 100%; + color: red; + margin-bottom: 15px; + border: 1px solid red; + border-radius: 6px; + padding: 3mm; +} + +.neutral { + color: white; + border-color: white; +} + +.warning { + color: orange; + border-color: orange; +} + +.success { + color: lightgreen; + border-color: lightgreen; +} + +.normtext { + background-color: #444444; + color: white; + border: none; +} + +.hili { + color: yellow; +} + +.tittab { + border-collapse: collapse; + width: 100%; + border: none; +} + +.tittab tr { + margin: 0; + padding: 0; +} + +.tittab td { + margin: 0; + padding: 1mm; + vertical-align: middle; +} + +.closeb { + cursor: pointer; + top: 3px; + vertical-align: middle; +} + +.inputdiv, .lastinputdiv, .outputdiv, .lastoutputdiv { + width: 100%; +} + +.inputdiv { + margin-bottom: 15px; +} + +.lastoutputdiv { + margin-top: 15px; +} + +.input, .inputx, .textarea, .button, .halfbutton, .copybutton, .output, .outputnobb, .outputli, .posthead, .lastborder, fieldset { + width: 100%; + border: 1px solid #555555; + border-radius: 0 6px 6px 6px; + font-size: 12pt; + margin: 0; + padding: 3px; +} + +.input, .inputx, .textarea { + font-family: "sans"; +} + +.inputx { + border-radius: 0 6px 0 0; +} + +.lastborder { + border-top: none; + border-radius: 0 0 6px 6px; +} + +fieldset { + padding: 6px; + margin-top: 2px; +} + +.halfbutton { + width: 50%; + height: 30px; + border-radius: 6px; +} + +.button, .copybutton { + height: 40px; + border-radius: 6px; + font-weight: bold; +} + +.copybutton { + border-top: none; + border-radius: 0 0 6px 6px; +} + +.output, .outputnobb { + border-radius: 0; + margin: 0; + font-family: "sans"; +} + +.outputnobb { + border-bottom: none; +} + +.outputli { + border-radius: 0 0 6px 6px; +} + +label { + max-width: 96%; + font-weight: bold; + color: white; + background-color: #555555; + border-bottom: none; + border-radius: 6px 6px 0 0; + padding: 2px 6px 3px 6px; + display: inline-block; + margin: 0; +} + +.cblab { + background-color: rgba(0, 0, 0, 0); + font-weight: normal; + display: table-cell; +} + +.posthead, .errposthead { + font-weight: bold; + font-size: 12pt; + margin-bottom: 0; + border-bottom: none; + border-radius: 6px 6px 0 0; + color: white; + background-color: #555555; + padding: 3px 6px 3px 6px; + margin-top: 15px; +} + +.errposthead { + background-color: red; +} + +.horsep { + width: 100%; + height: 25px; +} + +#footer, #almfooter { + width: 100%; + text-align: center; + font-size: 9pt; + margin: 3mm 0 0 0; +} + +#almfooter { + font-size: 10.5pt; +} diff --git a/imgs/icon-16.png b/imgs/icon-16.png new file mode 100644 index 0000000..03ac02e Binary files /dev/null and b/imgs/icon-16.png differ diff --git a/imgs/icon-180.png b/imgs/icon-180.png new file mode 100644 index 0000000..0ade583 Binary files /dev/null and b/imgs/icon-180.png differ diff --git a/imgs/icon-192.png b/imgs/icon-192.png new file mode 100644 index 0000000..433614d Binary files /dev/null and b/imgs/icon-192.png differ diff --git a/imgs/icon-32.png b/imgs/icon-32.png new file mode 100644 index 0000000..271c86c Binary files /dev/null and b/imgs/icon-32.png differ diff --git a/imgs/icon-512.png b/imgs/icon-512.png new file mode 100644 index 0000000..a052d85 Binary files /dev/null and b/imgs/icon-512.png differ diff --git a/imgs/ogimage.png b/imgs/ogimage.png new file mode 100644 index 0000000..68d8903 Binary files /dev/null and b/imgs/ogimage.png differ diff --git a/index.php b/index.php new file mode 100644 index 0000000..e83c91d --- /dev/null +++ b/index.php @@ -0,0 +1,205 @@ +. +*/ + +// example of a post censored by mastodon.uno: https://livellosegreto.it/@xabacadabra/110662830871887169 + +require 'lib/ght.php'; +require 'lib/ckratelimit.php'; + +const SNAME='unocck'; +const SVERS='0.1'; +const SREPO='https://git.lattuga.net/pongrebio/unocck'; +const MULEN=1024; +const INIFP='sec/conf.ini'; +const RLFP='sec/rl.state'; + +header('Content-Language: en'); + +$usname=ucfirst(SNAME); +$timeout=5; +$cjr=rand(0,999999); + +$conf=@parse_ini_file(INIFP,false,INI_SCANNER_RAW); +if ($conf===false) + die('Could not open configuration file.'); +elseif (!isset($conf['host']) || !isset($conf['hostdesc']) || !isset($conf['token']) || !isset($conf['maintref'])) + die('Configuration file is malformed.'); + +if (file_exists(RLFP)) { + $buf=@file(RLFP,FILE_IGNORE_NEW_LINES); + if ($buf===false) + die('Could not open rate limiting state file.'); + if (count($buf)!=2 || preg_match('#^\d+$#',$buf[0])!==1 || preg_match('#^\d+$#',$buf[1])!==1) + die('Malformed rate limiting state file.'); + $rl=['remaining'=>$buf[0]+0,'restime'=>$buf[1]+0]; +} else { + $rl=['remaining'=>400,'restime'=>0]; +} + +$now=time(); +if ($rl['remaining']==10 && $now<=$rl['restime'])// ten to leave a margin for "many people using it" + die("Sorry, this {$usname} instance has reached rate limit on «{$conf['host']}», please wait at least ".ght($rl['restime']-$now,null,0).'.'); + +$errors=[]; + +if (isset($argv[1])) + $_GET=['purl'=>$argv[1]]; + +if (isset($_GET['purl'])) { + if (strlen($_GET['purl'])>MULEN) { + $_GET['purl']=''; + $errors[]='“Post URL” is too long'; + } + $_GET['purl']=trim($_GET['purl']); + if ($_GET['purl']!='' && preg_match('#^https?://#',$_GET['purl'])!==1)// todo: make it better + $errors[]='“Post URL” is not a valid http(s) address'; +} else { + $_GET['purl']=''; +} + +if ($_GET['purl']!=='') + $purlhn=preg_replace('#^https?://([^/]+).*$#','$1',$_GET['purl']); + +if (count($errors)>0) + $errors='
There are some errors in the values you submitted
'; +else + $errors=''; + +echo " + + +{$usname} + + + + + + + + + + + + +
+

{$usname}

+
+

Hello, this is a {$usname} instance. {$usname} is a tool to easily check if a Mastodon instance is censoring a given post. This {$usname} instance is set to check «{$conf['host']}», {$conf['hostdesc']}. It works by querying {$conf['host']}’s /api/v2/search API endpoint with the credentials of an application defined inside a {$conf['host']} account, but to avoid false positives it does so only after it has verified that the URL you passed to it actually points to a publicly accessible ActivityPub object.

+

{$usname} does not use cookies or Javascript and does not store any data about you anywhere.

+

You can find {$usname}’s code here.

+

This {$usname} instance is maintained by {$conf['maintref']}.

+
+
+".$errors." +

Check

+
+
+
\n
\n"; + +if ($errors=='' && $_GET['purl']!='') { + echo "
\n"; + $context=[ + 'http'=>[ + 'header'=>"Content-type: application/x-www-form-urlencoded\r\nAccept: application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"\r\n", + 'method'=>'GET', + 'ignore_errors'=>true, + 'user_agent'=>'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0', + 'timeout'=>5 + ] + ]; + if (isset($conf['proxy'])) + $context['http']['proxy']=$conf['proxy']; + $http_response_header=null; + $res=@file_get_contents($_GET['purl'],false,stream_context_create($context)); + /*$xres=json_decode($res,true); + echo preprint($xres);*/ + if (isset($http_response_header)) + $hcode=gethttpcode($http_response_header); + if ($res===false || !isset($http_response_header)) { + echo "
Error: {$usname} could not connect to «{$purlhn}» and won’t proceed with the check.
\n"; + } elseif ($hcode[0]!='2') { + if ($hcode[0]=='5') + echo "
«{$purlhn}» returned a server error. {$usname} won’t proceed with the check.
\n"; + elseif ($hcode[0]=='4') + echo "
Error: {$usname} could not access the “Post URL” you passed to it: probably the post visibility is not public/unlisted, or you passed a wrong URL. {$usname} won’t proceed with the check.
\n"; + elseif ($hcode[0]=='3') + echo "
Error: the “Post URL” you passed to {$usname} redirects, and since its programmer is lazy, {$usname} currently only accepts URLs which point to original posts (on Mastodon web you can copy a post’s original URL by opening the “three vertical dots” menu you find on every post and selecting “Copy link to post”). {$usname} won’t proceed with the check.
\n"; + elseif ($hcode[0]=='1') + echo "
«{$purlhn}» returned an unexpected and useless informational message. {$usname} won’t proceed with the check.
\n"; + else + echo "
«{$purlhn}» returned an unexpected HTTP code. {$usname} won’t proceed with the check.
\n"; + } elseif (null===$res=@json_decode($res,true)) { + echo "
Error: «{$purlhn}» returned data which could not be parsed as JSON (".json_last_error().': '.json_last_error_msg().").
\n"; + } elseif (isset($res['error'])) { + echo "
Error: «{$purlhn}» returned «".htmlentities($res['error'])."». {$usname} won’t proceed with the check.
\n"; + } elseif (!isset($res['@context'][0]) || $res['@context'][0]!='https://www.w3.org/ns/activitystreams') { + echo "
Error: the “Post URL” you passed to {$usname} doesn’t point to an ActivityPub post. {$usname} won’t proceed with the check.
\n"; + } else { + $context['http']['header']="Content-type: application/x-www-form-urlencoded\r\nAccept: application/json\r\nAuthorization: Bearer {$conf['token']}\r\n"; + $http_response_header=null; + $hhost=htmlentities($conf['host']); + $url=$conf['host'].'/api/v2/search?q='.urlencode($_GET['purl']).'&type=statuses&resolve=1&limit=1'; + $hurl=htmlentities($url); + //while (true) { + $res=@file_get_contents('https://'.$url,false,stream_context_create($context)); + if (isset($http_response_header)) + $rl=ckratelimit($http_response_header,'echofun',true,false); + //echo preprint($rl); + if (is_array($rl) && @file_put_contents(RLFP,$rl['remaining']."\n".($rl['secstoreset']+time())."\n")===false) + echo "
Warning: could not write to rate limit state file.
\n"; + /*if ($rl['remaining']==0) + break; + usleep(250000); + }*/ + if ($res===false) { + echo "
Error: could not connect to «{$hhost}».
\n"; + } elseif (null===$res=@json_decode($res,true)) { + echo "
Error: «{$hurl}» returned data which could not be parsed as JSON (".json_last_error().': '.json_last_error_msg().").
\n"; + } elseif (isset($res['error'])) { + echo "
Error: «{$hurl}» replied with this error message: «".htmlentities($res['error'])."».
\n"; + } elseif (!isset($res['statuses'])) { + echo "
Error: «{$hurl}» returned data in an unexpected format.
\n"; + } else { + if (isset($res['statuses'][0]['id'])) + echo "
\nPost is not censored.\n
\n"; + else + echo "
\nPost is censored.\n
\n"; + } + } +} + +if (isset($conf['footer'])) + echo "
{$conf['footer']}
\n"; + +echo " +
+ +\n"; + +function preprint($var) { + return '
'.print_r($var,true)."
\n"; +} + +function gethttpcode($headers) { + return preg_replace('#^[^ ]+ (\d+).*$#','$1',$headers[0]); +} + +function echofun($msg) { + echo $msg; +} + +?> diff --git a/lib/ckratelimit.php b/lib/ckratelimit.php new file mode 100644 index 0000000..184bbfd --- /dev/null +++ b/lib/ckratelimit.php @@ -0,0 +1,36 @@ +$srvrlremain,'secstoreset'=>$secstoreset]; + if ($onlyret) + return $ret; + if ($verbose) $echofun("ckratelimit: X-RateLimit-Remaining: {$srvrlremain}; server time: {$srvnow}: ".gmdate('c',$srvnow).'; X-RateLimit-Reset: '.gmdate('c',$srvrlreset).'; current seconds before reset: '.$secstoreset.".\n"); + if ($srvrlremain==0) { + $echofun("Reached rate limit, waiting {$secstoreset} seconds for rate limit reset ...\n"); + sleep($secstoreset); + } + } else { + if ($verbose) $echofun("ckratelimit: no «Date» / «X-RateLimit-Reset» / «X-RateLimit-Remaining» header(s)!\n"); + } + } else { + if ($verbose) $echofun("ckratelimit: headers is not an array!\n"); + } + return $ret; +} +?> diff --git a/lib/ght.php b/lib/ght.php new file mode 100644 index 0000000..32844ab --- /dev/null +++ b/lib/ght.php @@ -0,0 +1,50 @@ +0) + ($x==1) ? $out.=$x.$fa[$i] : $out.=$x.$fa[$i+1]; + $ts=$ts-$x*31536000; + $i+=2; +// weeks + $x=floor($ts/604800); + if ($x>0) + ($x==1) ? $out.=$x.$fa[$i] : $out.=$x.$fa[$i+1]; + $ts=$ts-$x*604800; + $i+=2; +// days + $x=floor($ts/86400); + if ($x>0) + ($x==1) ? $out.=$x.$fa[$i] : $out.=$x.$fa[$i+1]; + $ts=$ts-$x*86400; + $i+=2; +// hours + $x=floor($ts/3600); + if ($x>0) + ($x==1) ? $out.=$x.$fa[$i] : $out.=$x.$fa[$i+1]; + $ts=$ts-$x*3600; + $i+=2; +// minutes + $x=floor($ts/60); + if ($x>0) + ($x==1) ? $out.=$x.$fa[$i] : $out.=$x.$fa[$i+1]; + $ts=$ts-$x*60; + $i+=2; +// seconds + $x=round($ts,$sd); + ($x==1) ? $out.=$x.$fa[$i] : $out.=$x.$fa[$i+1]; + return $out; +} + +?> diff --git a/sec/.htaccess b/sec/.htaccess new file mode 100644 index 0000000..93169e4 --- /dev/null +++ b/sec/.htaccess @@ -0,0 +1,2 @@ +Order deny,allow +Deny from all