diff --git a/README.md b/README.md index 3a2a48b..49eebbc 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,13 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ## 🛠 Installation and setup 0. You need to have a webhosting with **PHP 8** and perferable running Apache or similar for timeline to work. - + > There are free options, but I would suggest that you pay for your hosting and also get a nice domain, so you have more ownership over your data and online idetenty. 1. Download the code from https://github.com/sorenpeter/timeline as a zip 2. Upload the content of the zip to you webhosting using a FTP client - + - The default would be to put eveything from within the timeline-main folder in the root so you will have: ``` @@ -66,7 +66,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ### Webfinger endpoint setup -6. For allowing others to look you on using webfinger, you need to move the `.well-known` folder from within the `_webfinger_endpoint` to the root of your domain, so it is accesable from www.example.net/.well-know/webfinger +6. For allowing others to look you on using webfinger, you need to move the `.well-known` folder from within the `_webfinger_endpoint` to the root of your domain, so it is accesable from www.example.net/.well-know/webfinger 7. You also need to edit the `index.php` file wihtin the `.well-know/webfinger` folder and set the correct path for you timeline installation in `$timeline_dir` variable. @@ -84,6 +84,8 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ## 🐞 Bugs to fix - [x] (2024-11-30) Fix issues with parsing markdown vs. twtxt syntax (replaceed slimdown with Parsedown, supporting lists, block quotes, code/blocks, links, images) +- [x] (2024-12-26) Extend session duration for 30 days +- [ ] (2024-12-26) Read the config.ini in a centralized place and add validations useful when installing or upgrading `timeline`. ## 🚀 Features to code @@ -97,7 +99,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # 🙏 Credits / shoutouts -## Ideas and inspiration +## Ideas and inspiration - [twtxt](https://twtxt.readthedocs.io) - The original decentralised, minimalist microblogging service for hackers @@ -109,7 +111,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - [twtxt-php](https://github.com/eapl-gemugami/twtxt-php) - A minimalistic and personal PHP site for your twtxt microblogging. -- [Slimdown](https://github.com/jbroadway/slimdown) - A simple regex-based Markdown parser in PHP. +- [Slimdown](https://github.com/jbroadway/slimdown) - A simple regex-based Markdown parser in PHP. - Tag cloud feature is based on php code by [Domingos Faria](https://social.dfaria.eu/search) diff --git a/VERSION b/VERSION index a22da3b..9d56291 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2024.12.06 +2024.12.26 diff --git a/_wip_todo/add_url.php b/_wip_todo/add_url.php index 34494d1..886664c 100644 --- a/_wip_todo/add_url.php +++ b/_wip_todo/add_url.php @@ -10,9 +10,9 @@ if ($config['debug_mode']) { require_once('session.php'); -if (!isset($_SESSION['valid_session'])) { +if (!isset($_SESSION['valid_session'])) { $secretKey = $config['totp_secret']; - $cookieVal = decodeCookie($secretKey); + $cookieVal = isSavedCookieValid($secretKey); if ($cookieVal === false) { # Valid cookie ? header('Location: login.php'); @@ -56,27 +56,30 @@ if (isset($_POST['submit'])) { exit; } } else { ?> - - - - - twtxt - - - - -

twtxt

-
-
- -
URL is invalid, check it!

- - -
-
- -
-
- - + + + + + + twtxt + + + + + +

twtxt

+
+
+ +
URL is invalid, check it!

+ + +
+
+ +
+
+ + + \ No newline at end of file diff --git a/_wip_todo/session.php b/_wip_todo/session.php deleted file mode 100644 index 907fb00..0000000 --- a/_wip_todo/session.php +++ /dev/null @@ -1,101 +0,0 @@ - 'twtxt_session', - 'use_strict_mode' => true, - 'cookie_httponly' => true, - 'cookie_secure' => $config['secure_cookies'], - 'sid_length' => 64, - 'sid_bits_per_character' => 6, - 'cookie_samesite' => 'Strict', // Not compatible with PHP lower than 7.3 -]); - -function has_valid_session() { - $config = parse_ini_file('private/config.ini'); - $secretKey = $config['password']; - - if (isset($_SESSION['valid_session'])) { - return true; - } - - $cookieVal = decodeCookie($secretKey); - if ($cookieVal === false) { - #echo "Invalid cookie"; - return false; - } - - return true; -} - -function encrypt(string $data, string $key, string $method): string { - $ivSize = openssl_cipher_iv_length($method); - $iv = openssl_random_pseudo_bytes($ivSize); - $encrypted = openssl_encrypt($data, $method, $key, OPENSSL_RAW_DATA, $iv); - # PHP 8.2 - Deprecated: implode(): - # Passing null to parameter #1 ($separator) of type array|string is deprecated - //$encrypted = strtoupper(implode(null, unpack('H*', $encrypted))); - $encrypted = strtoupper(implode(unpack('H*', $encrypted))); - - return $encrypted; -} - -function decrypt(string $data, string $key, string $method): string { - $data = pack('H*', $data); - $ivSize = openssl_cipher_iv_length($method); - $iv = openssl_random_pseudo_bytes($ivSize); - $decrypted = openssl_decrypt($data, $method, $key, OPENSSL_RAW_DATA, $iv); - - return trim($decrypted); -} - -function saveLoginSuccess($secretKey) { - // Set a cookie to remember the user - $_SESSION['valid_session'] = true; - - // Set a cookie value to remember the user - $encoded_cookie_value = generateCookieValue('admin', $secretKey); - $cookie_expiry = time() + (30 * 24 * 60 * 60); // 30 days - - $config = parse_ini_file('private/config.ini'); - - setcookie(COOKIE_NAME, $encoded_cookie_value, [ - 'expires' => $cookie_expiry, - 'secure' => $config['secure_cookies'], - 'httponly' => true, - 'samesite' => 'Strict', - ]); -} - -function generateCookieValue($username, $secretKey) { - $key = bin2hex($secretKey); - - $encrypted = encrypt($username, $key, ENCRYPTION_METHOD); - return $encrypted; -} - -function decodeCookie($secretKey) { - // Retrieve the encoded cookie name - if (!isset($_COOKIE[COOKIE_NAME])) { - return false; - } - - $encoded_cookie_value = $_COOKIE[COOKIE_NAME]; - $key = bin2hex($secretKey); - - $config = parse_ini_file('private/config.ini'); - - // Extend expiry by 30 days - $cookie_expiry = time() + (30 * 24 * 60 * 60); - setcookie(COOKIE_NAME, $encoded_cookie_value, [ - 'expires' => $cookie_expiry, - 'secure' => $config['secure_cookies'], - 'httponly' => true, - 'samesite' => 'Strict', - ]); - - $decrypted = decrypt($encoded_cookie_value, $key, ENCRYPTION_METHOD); - return $decrypted; -} \ No newline at end of file diff --git a/index.php b/index.php index b4cb114..1e51db4 100644 --- a/index.php +++ b/index.php @@ -1,4 +1,4 @@ - 'new_twt.php', '/add' => 'add_feed.php', '/following' => 'following.php', - //'/refresh' => 'load_twt_files.php', '/refresh' => 'refresh.php', '/login' => 'login.php', '/logout' => 'logout.php', '/profile' => 'profile.php', '/replies' => 'replies.php', '/gallery' => 'gallery.php', - //'/profile/([a-zA-Z0-9_-]+)' => 'profile.php', - '/conv/([a-zA-Z0-9]{7})' => 'conv.php', // matches only twtHash of exactly 7 alphanumeric characters - '/post/([a-zA-Z0-9]{7})' => 'post.php', // matches only twtHash of exactly 7 alphanumeric characters - //'/thumb' => 'thumb.php', + '/conv/([a-zA-Z0-9]{7})' => 'conv.php', // matches only twtHash of exactly 7 alphanumeric characters + '/post/([a-zA-Z0-9]{7})' => 'post.php', // matches only twtHash of exactly 7 alphanumeric characters '/upload' => 'upload_img.php', '/webmention' => 'webmention_endpoint.php', + //'/thumb' => 'thumb.php', + //'/profile/([a-zA-Z0-9_-]+)' => 'profile.php', + + # Debug endpoints + '/test_login' => 'test_login.php', ]; // Loop through the defined routes and try to match the request URI foreach ($routes as $pattern => $action) { if (preg_match('#^' . $pattern . '$#', $path, $matches)) { - - // Extract any matched parameters (e.g., username) + + // Extract any matched parameters (e.g., username) if(!empty($matches[1])) { //array_shift($matches); $id = $matches[1]; diff --git a/libs/persistent_session.php b/libs/persistent_session.php new file mode 100644 index 0000000..cf444ec --- /dev/null +++ b/libs/persistent_session.php @@ -0,0 +1,138 @@ + !isset($config[$key])); + +if (!empty($missing_keys)) { + die('Missing required keys in config.ini: ' . implode(', ', $missing_keys)); +} + +if (strlen($config['secret_key']) < 32) { + die('Secret key in config.ini must be at least 32 characters long'); +} + +const COOKIE_NAME = 'timeline_login'; +const ENCRYPTION_METHOD = 'aes-256-cbc'; +const EXPIRATION_DAYS = 30; + +const HASH_LENGTH = 128; +const HASH_ALGORITHM = 'sha512'; + +session_start([ + 'name' => 'timeline_session', + 'use_strict_mode' => true, + 'cookie_httponly' => true, + 'cookie_secure' => $config['secure_cookies'], + 'sid_length' => 64, + 'sid_bits_per_character' => 6, + 'cookie_samesite' => 'Strict', # Not compatible with PHP < 7.3 +]); + +function hasValidSession(): bool { + # If short lived session is valid + if (isset($_SESSION['session_expiration']) && $_SESSION['session_expiration'] > time()) { + return true; + } + + # TODO: Check if the session has expired + # Add more protection to prevent session fixation + # https://owasp.org/www-community/attacks/Session_fixation + + # Otherwise, check the persistent cookie + return isSavedCookieValid(); +} + +function getCookieData() { + if (!isset($_COOKIE[COOKIE_NAME])) { + #echo "Cookie " . COOKIE_NAME . " not found"; + return false; + } + + $raw = base64_decode($_COOKIE[COOKIE_NAME]); + #var_dump($raw); + + # Cookie should be at least the size of the hash length. + # If it's not, we can just bail out + if (strlen($raw) < HASH_LENGTH) { + #echo "Didn't get minimum length"; + return false; + } + + $config = parse_ini_file('private/config.ini'); + + # The cookie data contains the actual data w/ the hash concatenated to the end, + # since the hash is a fixed length, we can extract the last hash_length chars + # to get the hash. + $hash = substr($raw, strlen($raw) - HASH_LENGTH, HASH_LENGTH); + $data = substr($raw, 0, - (HASH_LENGTH)); + + # Calculate the expected hash from the data. If the data has not been + # tampered with, $hash and $hash_calculated will be the same + $hash_calculated = hash_hmac(HASH_ALGORITHM, $data, $config['secret_key']); + + if ($hash_calculated !== $hash) { + #echo "Different HASH. Tempered data?"; + return False; + } + + if (intval($data) < time()) { + #echo "Cookie expired"; + return False; + } + + return $data; +} + +function makePersistentCookie() { + $config = parse_ini_file('private/config.ini'); + + $cookieExpiry = EXPIRATION_DAYS * 24 * 60 * 60 + time(); # X days + #$cookieExpiry = 10 + time(); # Debug value - 10 seconds + + # Calculate a hash for the data and append it to the end of the data string + $cookieValue = strval($cookieExpiry); + + $hash = hash_hmac(HASH_ALGORITHM, $cookieValue, $config['secret_key']); + $cookieValue .= $hash; + $cookieValue = base64_encode($cookieValue); + + # Also create the short-timed session + $_SESSION['session_expiration'] = $cookieExpiry; + + return setcookie(COOKIE_NAME, $cookieValue, [ + 'expires' => $cookieExpiry, + 'secure' => $config['secure_cookies'], + 'httponly' => true, + 'samesite' => 'Strict', + ]); +} + +function saveLogin() { + makePersistentCookie(); +} + +function isSavedCookieValid() { + $cookieExpiry = getCookieData(); + + if ($cookieExpiry === false) { + deletePersistentCookie(); + return false; + } + + # @eapl As it's implemented, the user has to login again in 30 days + # since the first login, which I think is a good compromise. + + # Refresh session + $_SESSION['session_expiration'] = intval($cookieExpiry); + + return true; +} + +function deletePersistentCookie() { + if (isset($_COOKIE[COOKIE_NAME])) { + unset($_COOKIE[COOKIE_NAME]); + setcookie(COOKIE_NAME, '', time() - 3600); + } +} diff --git a/libs/session.php b/libs/session.php index bb9f9fc..a920687 100644 --- a/libs/session.php +++ b/libs/session.php @@ -1,30 +1,36 @@ nick, "@")) { $str = $twtxtData->nick; $str = ltrim($str, "@"); @@ -465,17 +466,25 @@ function getTwtsFromTwtxtString($url) { } // Fallback for nick and url if not set in twtxt.txt - // TODO: Use nick from local follow list as fallback? if ($twtxtData->nick === "") { $str = parse_url($url, PHP_URL_HOST); $str = str_replace("www.", "", $str); - $str = explode(".", $str)[0]; // take the first [0] from splitting the host at "." + //$str = explode(".", $str)[0]; // take the first [0] from splitting the host at "." $twtxtData->nick = $str; } if ($twtxtData->mainURL === "") { $twtxtData->mainURL = $url; } + // Use only nick as handle if nick and domain is the same + $twtxtData->domain = parse_url($twtxtData->mainURL, PHP_URL_HOST); + + if ($twtxtData->nick === $twtxtData->domain) { + $twtxtData->domain = ""; + } else { + $twtxtData->domain = "@".$twtxtData->domain; + } + if (!str_starts_with($currentLine, '#')) { $explodedLine = explode("\t", $currentLine); @@ -550,6 +559,7 @@ function getTwtsFromTwtxtString($url) { $twt->emoji = $twtxtData->emoji; $twt->nick = $twtxtData->nick; $twt->mainURL = $twtxtData->mainURL; + $twt->domain = $twtxtData->domain; $twtxtData->twts[$timestamp] = $twt; } diff --git a/partials/listSelect.php b/partials/listSelect.php index 068de12..09c1b13 100644 --- a/partials/listSelect.php +++ b/partials/listSelect.php @@ -1,43 +1,43 @@
- Selected for both public and private lists - - if( isset($_SESSION['password'])) { - if($_SESSION['password']=="$password") { // Hacky login + // TODO: fix it so if List -> Selected for both public and private lists + + if (isset($_SESSION['password'])) { + if ($_SESSION['password'] == "$passwordInConfig") { // Hacky login // Private lists echo ""; foreach (glob("private/twtxt-*.txt") as $filename) { - if($filename == $_GET['lists']) $attr="selected"; - else $attr = ""; - $listName = $filename; - $listName = str_replace("private/twtxt-", "", $listName); - $listName = str_replace("_", " ", $listName); - $listName = str_replace(".txt", "", $listName); - echo ""; + if ($filename == $_GET['lists']) $attr = "selected"; + else $attr = ""; + $listName = $filename; + $listName = str_replace("private/twtxt-", "", $listName); + $listName = str_replace("_", " ", $listName); + $listName = str_replace(".txt", "", $listName); + echo ""; } // Public Lists echo ""; - } } + } - foreach (glob("twtxt-*.txt") as $filename) { - if($filename == $_GET['lists']) $attr="selected"; - else $attr = ""; - $listName = $filename; - $listName = str_replace("twtxt-", "", $listName); - $listName = str_replace("_", " ", $listName); - $listName = str_replace(".txt", "", $listName); - echo ""; - } + foreach (glob("twtxt-*.txt") as $filename) { + if ($filename == $_GET['lists']) $attr = "selected"; + else $attr = ""; + $listName = $filename; + $listName = str_replace("twtxt-", "", $listName); + $listName = str_replace("_", " ", $listName); + $listName = str_replace(".txt", "", $listName); + echo ""; + } ?> - - + +
\ No newline at end of file diff --git a/partials/lists.php b/partials/lists.php index 32dca1b..ac0d3a1 100644 --- a/partials/lists.php +++ b/partials/lists.php @@ -1,43 +1,43 @@
- Selected for both public and private lists - - if( isset($_SESSION['password'])) { - if($_SESSION['password']=="$password") { // Hacky login + // TODO: fix it so if List -> Selected for both public and private lists + + if (isset($_SESSION['password'])) { + if ($_SESSION['password'] == "$passwordInConfig") { // Hacky login // Private lists echo ""; foreach (glob("private/twtxt-*.txt") as $filename) { - if($filename == $_GET['list']) $attr="selected"; - else $attr = ""; - $listName = $filename; - $listName = str_replace("private/twtxt-", "", $listName); - $listName = str_replace("_", " ", $listName); - $listName = str_replace(".txt", "", $listName); - echo ""; + if ($filename == $_GET['list']) $attr = "selected"; + else $attr = ""; + $listName = $filename; + $listName = str_replace("private/twtxt-", "", $listName); + $listName = str_replace("_", " ", $listName); + $listName = str_replace(".txt", "", $listName); + echo ""; } // Public Lists echo ""; - } } + } - foreach (glob("twtxt-*.txt") as $filename) { - if($filename == $_GET['list']) $attr="selected"; - else $attr = ""; - $listName = $filename; - $listName = str_replace("twtxt-", "", $listName); - $listName = str_replace("_", " ", $listName); - $listName = str_replace(".txt", "", $listName); - //$filename = "TODO".$baseURL."/".$filename; - echo ""; - } + foreach (glob("twtxt-*.txt") as $filename) { + if ($filename == $_GET['list']) $attr = "selected"; + else $attr = ""; + $listName = $filename; + $listName = str_replace("twtxt-", "", $listName); + $listName = str_replace("_", " ", $listName); + $listName = str_replace(".txt", "", $listName); + //$filename = "TODO".$baseURL."/".$filename; + echo ""; + } ?> - - + +
\ No newline at end of file diff --git a/partials/profile_card.php b/partials/profile_card.php index 1799e6d..dc0635b 100644 --- a/partials/profile_card.php +++ b/partials/profile_card.php @@ -63,7 +63,7 @@ if ($is_gallery) {
- nick?>@mainURL, PHP_URL_HOST);?> + @nick?>domain?>

description?>

diff --git a/partials/timeline.php b/partials/timeline.php index ca4a666..1cf72af 100644 --- a/partials/timeline.php +++ b/partials/timeline.php @@ -15,7 +15,7 @@
- nick?>@mainURL, PHP_URL_HOST);?> + @nick?>domain?>
@@ -39,7 +39,7 @@ if (isset($_SESSION['password'])) { echo ' | Reply'; - } + } ?> @@ -49,9 +49,8 @@ Comment via email'; -} - -?> \ No newline at end of file +} \ No newline at end of file diff --git a/partials/webmentions_send.php b/partials/webmentions_send.php index ba8fa3b..54115fb 100644 --- a/partials/webmentions_send.php +++ b/partials/webmentions_send.php @@ -1,8 +1,8 @@ document.getElementById("refreshLabel").innerHTML = "Updating: '.$following[1].' ('.$i.'/'.$total.')"; - document.getElementById("refreshProgress").value = "'.$float.'"; - document.getElementById("refreshProgress").innerHTML = "'.$percent.'"; + document.getElementById("refreshProgress").value = "'.$float.'"; + document.getElementById("refreshProgress").innerHTML = "'.$percent.'"; '; updateCachedFile($following[1]); diff --git a/views/add_feed.php b/views/add_feed.php index 41f0e03..61f46c9 100644 --- a/views/add_feed.php +++ b/views/add_feed.php @@ -1,6 +1,9 @@ - + include 'partials/header.php'; + ?> -

Webfinger lookup

+

Webfinger lookup

-
- - -
-
-
- - - -

Add a new feed to follow

- -
-
- - - - + + +
- -
-
+
+ - + - +

Add a new feed to follow

+ +
+
+ + + + +
+ +
+
+ + + + \ No newline at end of file diff --git a/views/following.php b/views/following.php index a01c2ad..714e8cd 100644 --- a/views/following.php +++ b/views/following.php @@ -1,7 +1,7 @@ --> Nick URL - + Time ago - - - - - - - - - - - - twts)) { + + + + + + + + + + + + twts)) { - // Then test if latest twt is at start or end of file: - $resetVar = reset(getTwtsFromTwtxtString($currentFollower[1])->twts); - $endVar = end(getTwtsFromTwtxtString($currentFollower[1])->twts); - if ($resetVar->timestamp < $endVar->timestamp) { // TODO: this can be swapped to get time of first twt - echo $endVar->displayDate; - } else { - echo $resetVar->displayDate; + // Then test if latest twt is at start or end of file: + $resetVar = reset(getTwtsFromTwtxtString($currentFollower[1])->twts); + $endVar = end(getTwtsFromTwtxtString($currentFollower[1])->twts); + if ($resetVar->timestamp < $endVar->timestamp) { // TODO: this can be swapped to get time of first twt + echo $endVar->displayDate; + } else { + echo $resetVar->displayDate; + } } - } - ?> + ?> - - + + - + - + \ No newline at end of file diff --git a/views/home.php b/views/home.php index a326e6d..a8d0ae0 100644 --- a/views/home.php +++ b/views/home.php @@ -18,25 +18,23 @@ if (!empty($_GET['profile'])) { // Show twts for some user (Profile view) // Load twts, taking $paginateTwts into consideration require_once 'partials/base.php'; +require_once 'libs/session.php'; -$title = "Timeline for ".$title; +$title = "Timeline for $title"; - -// Redirect guests to Profile view, if url not set til home twtxt.txt - -if (!isset($_SESSION['password']) && (isset($_GET['url']))) { - if ($_GET['url'] != $config['public_txt_url']) { - header('Location: ./profile'); - exit(); - } +// Redirect guests to Profile view, if URL isn't set to home twtxt.txt +if (!hasValidSession() && isset($_GET['url'])) { + if ($_GET['url'] != $config['public_txt_url']) { + header('Location: ./profile'); + exit(); + } } include_once 'partials/header.php'; -if (isset($_SESSION['password'])) { +if (hasValidSession()) { include 'views/new_twt.php'; // TODO: Split up new_twt into a view and a partial } else { - echo '

Timeline

'; echo '

Recent posts from feeds followed by diff --git a/views/login.php b/views/login.php index dfa0d93..116824d 100644 --- a/views/login.php +++ b/views/login.php @@ -1,28 +1,30 @@ -

-

Enter password or TOTP

-
-
- -

-
-
+ +
+

Enter password or TOTP

+
+
+ +

+ +

+
+
- + \ No newline at end of file diff --git a/views/logout.php b/views/logout.php index c44d92b..3f1c868 100644 --- a/views/logout.php +++ b/views/logout.php @@ -1,6 +1,7 @@ ";