Bitcoin Forum
May 10, 2024, 11:17:34 PM *
News: Latest Bitcoin Core release: 27.0 [Torrent]
 
  Home Help Search Login Register More  
  Show Posts
Pages: [1] 2
1  Other / Meta / Identifying the linked-to post in a topic (SMF patch) on: April 04, 2024, 01:31:44 AM
Hey, everybody! Smiley

This suggestion came from Cyrus, and I think it's a really good idea, so I thought I'd sink some time into it, and put together a patch.

Basically, the idea is to make it easier to identify the target post after clicking an ordinary post link, like: https://bitcointalk.org/index.php?topic=4193.msg60743#msg60743.

Normally, the (optional) fragment identifier at the end of the URL (#msg60743 in the example link) suffices to position the viewport so that the linked-to post is obvious/unambiguous. But, sometimes that mechanism isn't precise enough, like when the post is the last or second-to-last one on its page (then, I often find myself hovering over the subject link and comparing it to the address bar to confirm that I'm reading the right post).

I also sometimes lose track of the linked-to post when I scroll around the page for a bit to get some context before reading the actual post (then, I find myself going to the address bar and hitting Enter so that the browser snaps to the right post again).

In the worst case, both of those situations occur: After landing on the relevant topic-page, you scroll around a bit for some context, then you use the Enter trick to snap to the right post, but that's not reliable for the last or second-to-last post, so you have to do the address-comparing thing too. Cheesy

I also have to imagine that report-handling would be a little more pleasant for moderators if they had a reliable way to always quickly identify the linked-to post.

Anyway, it's tempting to make the linked-to post really stand out by putting a differently-colored background or border on it, or an inset/outset box-shadow or something, but, all the variations of that idea that I tried ended up looking pretty naff (and, anyway, I don't really like messing with the SMF aesthetic that a lot of veteran members prefer as-is). In this case, I think it's wiser to just do something really subtle and "quiet" (after all, the forum has been just fine for a long time without this, so I don't think it should be something that jumps out from the page and draws your eye too much; it should just be something that people who know what to look for, will look for).

I've done this patch in two styles:

Style A

This one just makes the subject-line of the linked-to post render differently than normal (with font-style: italic).



Code:
--- baseline/Sources/Display.php	2011-02-07 16:45:09.000000000 +0000
+++ modified/Sources/Display.php 2024-04-04 00:02:44.000000000 +0000
@@ -230,24 +230,25 @@
  SELECT COUNT(*)
  FROM {$db_prefix}messages
  WHERE ID_MSG < $virtual_msg
  AND ID_TOPIC = $topic", __FILE__, __LINE__);
  list ($context['start_from']) = mysql_fetch_row($request);
  mysql_free_result($request);
  }
 
  // We need to reverse the start as well in this case.
  $_REQUEST['start'] = empty($options['view_newest_first']) ? $context['start_from'] : $topicinfo['numReplies'] - $context['start_from'];
 
  $context['robot_no_index'] = true;
+ $context['requested_msg'] = $virtual_msg;
  }
  }
 
  // Create a previous next string if the selected theme has it as a selected option.
  $context['previous_next'] = $modSettings['enablePreviousNext'] ? '<a href="' . $scripturl . '?topic=' . $topic . '.0;prev_next=prev#new">' . $txt['previous_next_back'] . '</a> <a href="' . $scripturl . '?topic=' . $topic . '.0;prev_next=next#new">' . $txt['previous_next_forward'] . '</a>' : '';
 
  // Check if spellchecking is both enabled and actually working. (for quick reply.)
  $context['show_spellchecking'] = !empty($modSettings['enableSpellChecking']) && function_exists('pspell_new');
 
  // Censor the title...
  censorText($topicinfo['subject']);
  $context['page_title'] = $topicinfo['subject'];

Code:
--- baseline/Themes/default/Display.template.php	2010-10-22 01:38:35.000000000 +0000
+++ modified/Themes/default/Display.template.php 2024-04-04 00:03:41.000000000 +0000
@@ -359,32 +359,33 @@
  echo '
  <a href="', $scripturl, '?action=pm;sa=send;u=', $message['member']['id'], '" title="', $message['member']['online']['label'], '">', $settings['use_image_buttons'] ? '<img src="' . $settings['images_url'] . '/im_' . ($message['member']['online']['is_online'] ? 'on' : 'off') . '.gif" alt="' . $message['member']['online']['label'] . '" border="0" />' : $message['member']['online']['label'], '</a>';
  }
  }
  // Otherwise, show the guest's email.
  elseif (empty($message['member']['hide_email']))
  echo '
  <br />
  <br />
  <a href="mailto:', $message['member']['email'], '">', ($settings['use_image_buttons'] ? '<img src="' . $settings['images_url'] . '/email_sm.gif" alt="' . $txt[69] . '" title="' . $txt[69] . '" border="0" />' : $txt[69]), '</a>';
 
  // Done with the information about the poster... on to the post itself.
+ $is_requested_message = isset($context['requested_msg']) && $context['requested_msg'] == $message['id'];
  echo '
  </div>
  </td>
  <td valign="top" width="85%" height="100%">
  <table width="100%" border="0"><tr>
  <td valign="middle"><a href="', $message['href'], '"><img src="', $message['icon_url'] . '" alt="" border="0" /></a></td>
  <td valign="middle">
- <div style="font-weight: bold;" id="subject_', $message['id'], '">
+ <div style="font-weight: bold;', $is_requested_message ? ' font-style: italic;' : '', '" id="subject_', $message['id'], '">
  <a href="', $message['href'], '">', $message['subject'], '</a>
  </div>';
 
  // If this is the first post, (#0) just say when it was posted - otherwise give the reply #.
  echo '
  <div class="smalltext">&#171; <b>', !empty($message['counter']) ? $txt[146] . ' #' . $message['counter'] : '', ' ', $txt[30], ':</b> ', $message['time'], ' &#187;</div></td>
  <td align="', !$context['right_to_left'] ? 'right' : 'left', '" valign="bottom" height="20" style="font-size: smaller;">';
 
  // Can they reply? Have they turned on quick reply?
  if ($context['can_reply'] && !empty($options['display_quick_reply']))
  echo '
  <a href="', $scripturl, '?action=post;quote=', $message['id'], ';topic=', $context['current_topic'], '.', $context['start'], ';num_replies=', $context['num_replies'], ';sesc=', $context['session_id'], '" onclick="doQuote(', $message['id'], ', \'', $context['session_id'], '\'); return false;">', $reply_button, '</a>';

Style B

This one places a small, low-opacity Unicode arrow just before the subject-line of the linked-to post.



Code:
--- baseline/Sources/Display.php	2011-02-07 16:45:09.000000000 +0000
+++ modified/Sources/Display.php 2024-04-04 00:02:44.000000000 +0000
@@ -230,24 +230,25 @@
  SELECT COUNT(*)
  FROM {$db_prefix}messages
  WHERE ID_MSG < $virtual_msg
  AND ID_TOPIC = $topic", __FILE__, __LINE__);
  list ($context['start_from']) = mysql_fetch_row($request);
  mysql_free_result($request);
  }
 
  // We need to reverse the start as well in this case.
  $_REQUEST['start'] = empty($options['view_newest_first']) ? $context['start_from'] : $topicinfo['numReplies'] - $context['start_from'];
 
  $context['robot_no_index'] = true;
+ $context['requested_msg'] = $virtual_msg;
  }
  }
 
  // Create a previous next string if the selected theme has it as a selected option.
  $context['previous_next'] = $modSettings['enablePreviousNext'] ? '<a href="' . $scripturl . '?topic=' . $topic . '.0;prev_next=prev#new">' . $txt['previous_next_back'] . '</a> <a href="' . $scripturl . '?topic=' . $topic . '.0;prev_next=next#new">' . $txt['previous_next_forward'] . '</a>' : '';
 
  // Check if spellchecking is both enabled and actually working. (for quick reply.)
  $context['show_spellchecking'] = !empty($modSettings['enableSpellChecking']) && function_exists('pspell_new');
 
  // Censor the title...
  censorText($topicinfo['subject']);
  $context['page_title'] = $topicinfo['subject'];

Code:
--- baseline/Themes/default/Display.template.php	2010-10-22 01:38:35.000000000 +0000
+++ modified/Themes/default/Display.template.php 2024-04-04 00:05:27.000000000 +0000
@@ -359,33 +359,34 @@
  echo '
  <a href="', $scripturl, '?action=pm;sa=send;u=', $message['member']['id'], '" title="', $message['member']['online']['label'], '">', $settings['use_image_buttons'] ? '<img src="' . $settings['images_url'] . '/im_' . ($message['member']['online']['is_online'] ? 'on' : 'off') . '.gif" alt="' . $message['member']['online']['label'] . '" border="0" />' : $message['member']['online']['label'], '</a>';
  }
  }
  // Otherwise, show the guest's email.
  elseif (empty($message['member']['hide_email']))
  echo '
  <br />
  <br />
  <a href="mailto:', $message['member']['email'], '">', ($settings['use_image_buttons'] ? '<img src="' . $settings['images_url'] . '/email_sm.gif" alt="' . $txt[69] . '" title="' . $txt[69] . '" border="0" />' : $txt[69]), '</a>';
 
  // Done with the information about the poster... on to the post itself.
+ $is_requested_message = isset($context['requested_msg']) && $context['requested_msg'] == $message['id'];
  echo '
  </div>
  </td>
  <td valign="top" width="85%" height="100%">
  <table width="100%" border="0"><tr>
  <td valign="middle"><a href="', $message['href'], '"><img src="', $message['icon_url'] . '" alt="" border="0" /></a></td>
  <td valign="middle">
  <div style="font-weight: bold;" id="subject_', $message['id'], '">
- <a href="', $message['href'], '">', $message['subject'], '</a>
+ ', $is_requested_message ? '<span style="opacity: 30%;">&#8702;&nbsp;</span>' : '', '<a href="', $message['href'], '">', $message['subject'], '</a>
  </div>';
 
  // If this is the first post, (#0) just say when it was posted - otherwise give the reply #.
  echo '
  <div class="smalltext">&#171; <b>', !empty($message['counter']) ? $txt[146] . ' #' . $message['counter'] : '', ' ', $txt[30], ':</b> ', $message['time'], ' &#187;</div></td>
  <td align="', !$context['right_to_left'] ? 'right' : 'left', '" valign="bottom" height="20" style="font-size: smaller;">';
 
  // Can they reply? Have they turned on quick reply?
  if ($context['can_reply'] && !empty($options['display_quick_reply']))
  echo '
  <a href="', $scripturl, '?action=post;quote=', $message['id'], ';topic=', $context['current_topic'], '.', $context['start'], ';num_replies=', $context['num_replies'], ';sesc=', $context['session_id'], '" onclick="doQuote(', $message['id'], ', \'', $context['session_id'], '\'); return false;">', $reply_button, '</a>';

As always, everyone's thoughts/notes/votes are appreciated. Wink

@theymos: Style A doesn't add or remove any markup, so post scrapers (probably) won't be affected by the change. Style B introduces a new <span> element just before the <a> element that wraps the subject-line, so if that's the one you like (assuming that you like either one to begin with), then LoyceV and TryNinja would probably appreciate some notice before you merge it, so that they can tweak their scrapers if necessary.
2  Other / Meta / A copy button for [code] blocks (SMF patch) on: February 19, 2024, 07:01:55 PM
This is something @libert19 suggested last year. I think it's a good idea: manually copy-pasting BBCode to update your signature is a bit error-prone (not everyone is as careful as they should be, and, although I don't really mess with mobile, I'm guessing that it's even more finicky on a phone/tablet). Besides that, it's just a nice, unobtrusive little time-saver (I bump into [code] blocks quite often on Bitcointalk, so I think this will be pretty handy beyond the use-case that inspired it).

Thanks to @TryNinja for posting a userscript that I took a peek at as a starting point.

I've put together two variations of this patch, here's what style A (default styling with just a hair of margin-bottom) looks like:



And here's what style B (the same border as [quote] blocks, which ends up working pretty nicely, IMO) looks like:



(I think style B works/fits a bit better, personally. Especially when there are small and/or multiple code blocks, like at the bottom of this post, I think style A will draw too much attention to itself.)

Here's the diff for @theymos (style A):

Code:
--- baseline/Sources/Subs.php	2011-09-17 21:59:55.000000000 +0000
+++ modified/Sources/Subs.php 2024-02-19 11:28:27.000000000 +0000
@@ -1123,7 +1123,7 @@
  array(
  'tag' => 'code',
  'type' => 'unparsed_content',
- 'content' => '<div class="codeheader">' . $txt['smf238'] . ':</div><div class="code">' . ($context['browser']['is_gecko'] ? '<pre style="margin-top: 0; display: inline;">$1</pre>' : '$1') . '</div>',
+ 'content' => '<div class="codeheader">' . $txt['smf238'] . ': <button type="button" style="margin-bottom: 1px;" onclick="navigator.clipboard.writeText(this.parentElement.nextElementSibling.innerText);">Copy</button></div><div class="code">' . ($context['browser']['is_gecko'] ? '<pre style="margin-top: 0; display: inline;">$1</pre>' : '$1') . '</div>',
  // !!! Maybe this can be simplified?
  'validate' => isset($disabled['code']) ? null : create_function('&$tag, &$data, $disabled', '
  global $context;
@@ -1161,7 +1161,7 @@
  array(
  'tag' => 'code',
  'type' => 'unparsed_equals_content',
- 'content' => '<div class="codeheader">' . $txt['smf238'] . ': ($2)</div><div class="code">' . ($context['browser']['is_gecko'] ? '<pre style="margin-top: 0; display: inline;">$1</pre>' : '$1') . '</div>',
+ 'content' => '<div class="codeheader">' . $txt['smf238'] . ': ($2) <button type="button" style="margin-bottom: 1px;" onclick="navigator.clipboard.writeText(this.parentElement.nextElementSibling.innerText);">Copy</button></div><div class="code">' . ($context['browser']['is_gecko'] ? '<pre style="margin-top: 0; display: inline;">$1</pre>' : '$1') . '</div>',
  // !!! Maybe this can be simplified?
  'validate' => isset($disabled['code']) ? null : create_function('&$tag, &$data, $disabled', '
  global $context;

And here's the diff for style B:

Code:
--- baseline/Sources/Subs.php	2011-09-17 21:59:55.000000000 +0000
+++ modified/Sources/Subs.php 2024-02-19 11:28:40.000000000 +0000
@@ -1123,7 +1123,7 @@
  array(
  'tag' => 'code',
  'type' => 'unparsed_content',
- 'content' => '<div class="codeheader">' . $txt['smf238'] . ':</div><div class="code">' . ($context['browser']['is_gecko'] ? '<pre style="margin-top: 0; display: inline;">$1</pre>' : '$1') . '</div>',
+ 'content' => '<div class="codeheader">' . $txt['smf238'] . ': <button type="button" style="border: 1px solid #d0d0e0;" onclick="navigator.clipboard.writeText(this.parentElement.nextElementSibling.innerText);">Copy</button></div><div class="code">' . ($context['browser']['is_gecko'] ? '<pre style="margin-top: 0; display: inline;">$1</pre>' : '$1') . '</div>',
  // !!! Maybe this can be simplified?
  'validate' => isset($disabled['code']) ? null : create_function('&$tag, &$data, $disabled', '
  global $context;
@@ -1161,7 +1161,7 @@
  array(
  'tag' => 'code',
  'type' => 'unparsed_equals_content',
- 'content' => '<div class="codeheader">' . $txt['smf238'] . ': ($2)</div><div class="code">' . ($context['browser']['is_gecko'] ? '<pre style="margin-top: 0; display: inline;">$1</pre>' : '$1') . '</div>',
+ 'content' => '<div class="codeheader">' . $txt['smf238'] . ': ($2) <button type="button" style="border: 1px solid #d0d0e0;" onclick="navigator.clipboard.writeText(this.parentElement.nextElementSibling.innerText);">Copy</button></div><div class="code">' . ($context['browser']['is_gecko'] ? '<pre style="margin-top: 0; display: inline;">$1</pre>' : '$1') . '</div>',
  // !!! Maybe this can be simplified?
  'validate' => isset($disabled['code']) ? null : create_function('&$tag, &$data, $disabled', '
  global $context;



@hd49728: Thanks for adding my patches to your topic, I appreciate it. I noticed that one of the titles is rendering incorrectly: Adding   (non-breaking space) to the BBCode parser instead of Adding [nbsp] (non-breaking space) to the BBCode parser. That title probably rendered correctly at the time you added it, but, since that patch was accepted, [nbsp] has become valid BBCode, so now it needs to be escaped if you want it to display properly. You'll probably run into a similar problem with this title, too. Here's a tip: whenever you want a BBCode tag (or something that might become a BBCode tag) to render without being processed, remember to wrap it in [nobbc] tags (you can quote this post and examine this paragraph to see what I mean).
3  Other / Meta / A small merit-sending UI tweak on: November 03, 2023, 11:15:22 PM
There's a small UI issue with the merit-sending page that makes it easier than it should be to accidentally send more merit than you intended (I'm sure I've run into a handful of anecdotes about this, but I can't find them now).

Basically, the "Merit points" field is pre-populated with a 0, like this:



People are used to having to get rid of that 0 before entering an amount (or, they have to make sure that the amount they enter is after that 0). Sometimes, people are careless or tired, and accidentally enter something on the wrong side of that 0 (maybe believing that they've already deleted or selected it after mis-hitting a key, or something), and they end up sending more merit than they meant to (that is, sending 10, 20, 30, 40 or 50 merits by mistake is an easier thing to do than it should be).

For comparison with what follows, the current HTML for that input looks like this: <input type="text" size="6" name="merits" value="0">.

Here are some ways that I think this could be improved:

Approach A

This approach would leave the field initially empty so that there's no 0 there to trip anyone up.

That would look like this:



The HTML would change to: <input type="text" size="6" name="merits"> (that is, value="0" would be removed).

Approach B

This approach would add a bit of JavaScript that "selects" the value whenever it's clicked, that way, whatever you type in will replace whatever was already there.

That would look like this (after clicking):



The HTML would change to: <input type="text" size="6" name="merits" value="0" onclick="this.select();"> (that is, onclick="this.select();" would be added).

Approach C

This approach would leave the 0 there, but make it "placeholder" text instead of actual text, that way, it's just there as a cue, but the field is actually empty (the 0 will disappear as soon as you type something in).

That would look like this:



The HTML would change to: <input type="text" size="6" name="merits" placeholder="0"> (that is, value="0" would change to placeholder="0").

Closing thoughts

One disadvantage of approach A is that it leaves the user wondering what they're meant to type in (that's obviously not an issue for people that have sent merit before, but I could see brand new users getting confused without any cues to help them).

One disadvantage of approach B is that it requires JavaScript to function, and there's a (pretty small in this case, IMHO) chance that cross-browser weirdness will make it unreliable. One advantage of approach B is that it will catch a wider range of mistakes (sometimes people initially decide to send 1 merit, and then change that to a 2, but actually end up sending 12 or 21, by mistake. If the entire value gets selected each time you click the input, then those kinds of mistakes would be much harder to make).

Approach C won't catch as many mistakes as approach B, but it's simple, won't leave new users confused, and doesn't need JavaScript to work.

I would have included PHP diffs with this post, but I don't have access to the relevant code, so all I can do this time around is make suggestions like a noob. Cheesy

(I'm taking a break from the forum, but I'll be back on the 27th; try not to miss me too much.) Tongue
4  Other / Meta / Topic title RTL weirdness (SMF patch) on: October 02, 2023, 03:10:32 PM
This is a fix for an old issue that Foxpup diagnosed:

The cause is that Arabic text automatically switches the text direction to right-to-left, and since the immediately following text is just numbers and punctuation marks, which can written in either direction, there's nothing to change it back.

Mods can fix it in this instance by adding a left-to-right mark (U+200E, &#8206;) to the end of the thread title, though that doesn't address the underlying bug.

Basically, topic titles ending with certain characters will cause the page numbers to mis-render, like this:



It's easy to find many examples of this on the Arabic and Hebrew local boards, like the following:



With this patch, the two previous examples would render like so:





Here's the diff for @theymos:

Code:
--- baseline/Sources/MessageIndex.php	2011-12-22 22:56:39.000000000 +0000
+++ modified/Sources/MessageIndex.php 2023-10-02 14:44:40.000000000 +0000
@@ -495,32 +495,32 @@
 
  // Decide how many pages the topic should have.
  $topic_length = $row['numReplies'] + 1;
  if ($topic_length > $modSettings['defaultMaxMessages'])
  {
  $tmppages = array();
  $tmpa = 1;
  for ($tmpb = 0; $tmpb < $topic_length; $tmpb += $modSettings['defaultMaxMessages'])
  {
  $tmppages[] = '<a href="' . $scripturl . '?topic=' . $row['ID_TOPIC'] . '.' . $tmpb . '">' . $tmpa . '</a>';
  $tmpa++;
  }
  // Show links to all the pages?
  if (count($tmppages) <= 5)
- $pages = '&#171; ' . implode(' ', $tmppages);
+ $pages = '&lrm;&#171; ' . implode(' ', $tmppages);
  // Or skip a few?
  else
- $pages = '&#171; ' . $tmppages[0] . ' ' . $tmppages[1] . ' ... ' . $tmppages[count($tmppages) - 2] . ' ' . $tmppages[count($tmppages) - 1];
+ $pages = '&lrm;&#171; ' . $tmppages[0] . ' ' . $tmppages[1] . ' ... ' . $tmppages[count($tmppages) - 2] . ' ' . $tmppages[count($tmppages) - 1];
 
  if (!empty($modSettings['enableAllMessages']) && $topic_length < $modSettings['enableAllMessages'])
  $pages .= ' &nbsp;<a href="' . $scripturl . '?topic=' . $row['ID_TOPIC'] . '.0;all">' . $txt[190] . '</a>';
  $pages .= ' &#187;';
  }
  else
  $pages = '';
 
  // We need to check the topic icons exist...
  if (empty($modSettings['messageIconChecks_disable']))
  {
  if (!isset($context['icon_sources'][$row['firstIcon']]))
  $context['icon_sources'][$row['firstIcon']] = file_exists($settings['theme_dir'] . '/images/post/' . $row['firstIcon'] . '.gif') ? 'images_url' : 'default_images_url';
  if (!isset($context['icon_sources'][$row['lastIcon']]))

Edit: Adding a second diff because I noticed that this issue also shows up on ?action=unread, ?action=unreadreplies and ?action=watchlist.

Code:
--- baseline/Sources/Recent.php	2011-02-07 16:45:09.000000000 +0000
+++ modified/Sources/Recent.php 2023-10-06 04:52:26.000000000 +0000
@@ -981,32 +981,32 @@
 
  // Decide how many pages the topic should have.
  $topic_length = $row['numReplies'] + 1;
  if ($topic_length > $modSettings['defaultMaxMessages'])
  {
  $tmppages = array();
  $tmpa = 1;
  for ($tmpb = 0; $tmpb < $topic_length; $tmpb += $modSettings['defaultMaxMessages'])
  {
  $tmppages[] = '<a href="' . $scripturl . '?topic=' . $row['ID_TOPIC'] . '.' . $tmpb . ';topicseen">' . $tmpa . '</a>';
  $tmpa++;
  }
  // Show links to all the pages?
  if (count($tmppages) <= 5)
- $pages = '&#171; ' . implode(' ', $tmppages);
+ $pages = '&lrm;&#171; ' . implode(' ', $tmppages);
  // Or skip a few?
  else
- $pages = '&#171; ' . $tmppages[0] . ' ' . $tmppages[1] . ' ... ' . $tmppages[count($tmppages) - 2] . ' ' . $tmppages[count($tmppages) - 1];
+ $pages = '&lrm;&#171; ' . $tmppages[0] . ' ' . $tmppages[1] . ' ... ' . $tmppages[count($tmppages) - 2] . ' ' . $tmppages[count($tmppages) - 1];
 
  if (!empty($modSettings['enableAllMessages']) && $topic_length < $modSettings['enableAllMessages'])
  $pages .= ' &nbsp;<a href="' . $scripturl . '?topic=' . $row['ID_TOPIC'] . '.0;all">' . $txt[190] . '</a>';
  $pages .= ' &#187;';
  }
  else
  $pages = '';
 
  // We need to check the topic icons exist... you can never be too sure!
  if (empty($modSettings['messageIconChecks_disable']))
  {
  // First icon first... as you'd expect.
  if (!isset($context['icon_sources'][$row['firstIcon']]))
  $context['icon_sources'][$row['firstIcon']] = file_exists($settings['theme_dir'] . '/images/post/' . $row['firstIcon'] . '.gif') ? 'images_url' : 'default_images_url';
5  Other / Meta / Encouraging non-lazy PM subjects (SMF patch) on: September 18, 2023, 02:17:33 AM
I already posted about this (here), but almost instantly regretted doing that (I like the pattern of creating a new topic for each SMF patch, so I'm not sure why I tried to do things differently this time). Undecided

Anyway, this one came about from joker_josue pointing out that receiving PMs with no subject is annoying, and Loyce (effectively) nodding his head and saying "patch time?". Cheesy

It's not something that bugs me all that much (out of the 203 PMs I've received, 15 don't have a subject), but I can imagine it being really annoying for people with thousands of PMs in their inbox who find themselves looking back through them from time to time. Also, I've got some ideas for future patches that might allow for quickly isolating PMs by author, or subject, so phasing out (or, at least discouraging) PMs with "(No subject)" as their subject line, just seems like a good idea all-around to me.

Currently, the subject line is pre-populated with the text "(No subject)", like this:



To stop lazy MFs from just going with the default, this patch leaves the subject line empty:



If you try to send (or preview) a PM without attempting to describe what you're about to bang on about, then you'll get an error, like this:



Here's the diff for @theymos:

Code:
--- baseline/Sources/PersonalMessage.php	2011-02-07 16:45:09.000000000 +0000
+++ modified/Sources/PersonalMessage.php 2023-09-16 00:58:49.000000000 +0000
@@ -1224,33 +1224,33 @@
 
  $request = db_query("
  SELECT realName
  FROM {$db_prefix}members
  WHERE ID_MEMBER IN (" . implode(', ', $_REQUEST['u']) . ")
  LIMIT " . count($_REQUEST['u']), __FILE__, __LINE__);
  while ($row = mysql_fetch_assoc($request))
  $membersTo[] = '&quot;' . $row['realName'] . '&quot;';
  mysql_free_result($request);
  }
 
  // Create the 'to' string - Quoting it, just in case it's something like bob,i,like,commas,man.
  $_REQUEST['to'] = implode(', ', $membersTo);
  }
 
  // Set the defaults...
- $context['subject'] = $form_subject != '' ? $form_subject : $txt[24];
+ $context['subject'] = $form_subject;
  $context['message'] = str_replace(array('"', '<', '>'), array('&quot;', '&lt;', '&gt;'), $form_message);
  $context['to'] = isset($_REQUEST['to']) ? stripslashes($_REQUEST['to']) : '';
  $context['bcc'] = isset($_REQUEST['bcc']) ? stripslashes($_REQUEST['bcc']) : '';
  $context['post_error'] = array();
  $context['copy_to_outbox'] = !empty($options['copy_to_outbox']);
 
  // And build the link tree.
  $context['linktree'][] = array(
  'url' => $scripturl . '?action=pm;sa=send',
  'name' => $txt[321]
  );
 
  $context['visual_verification'] = !$user_info['is_admin'] && !empty($modSettings['pm_posts_verification']) && $user_info['posts'] < $modSettings['pm_posts_verification'];
  if ($context['visual_verification'])
  {
  $context['use_graphic_library'] = in_array('gd', get_loaded_extensions());

Edit: Adding a second diff which I think improves this patch quite a bit (read more below).

Code:
--- baseline/Themes/default/PersonalMessage.template.php	2006-12-01 15:43:03.000000000 +0000
+++ modified/Themes/default/PersonalMessage.template.php 2023-10-31 02:09:46.000000000 +0000
@@ -910,33 +910,33 @@
  <td align="right"><b', (isset($context['post_error']['no_to']) || isset($context['post_error']['bad_to']) ? ' style="color: red;"' : ''), '>', $txt[150], ':</b></td>
  <td class="smalltext">
  <input type="text" name="to" id="to" value="', $context['to'], '" tabindex="', $context['tabindex']++, '" size="40" />&nbsp;
  <a href="', $scripturl, '?action=findmember;input=to;quote=1;sesc=', $context['session_id'], '" onclick="return reqWin(this.href, 350, 400);"><img src="', $settings['images_url'], '/icons/assist.gif" alt="', $txt['find_members'], '" /></a> <a href="', $scripturl, '?action=findmember;input=to;quote=1;sesc=', $context['session_id'], '" onclick="return reqWin(this.href, 350, 400);">', $txt['find_members'], '</a>
  </td>
  </tr><tr>
  <td align="right"><b', (isset($context['post_error']['bad_bcc']) ? ' style="color: red;"' : ''), '>', $txt[1502], ':</b></td>
  <td class="smalltext">
  <input type="text" name="bcc" id="bcc" value="', $context['bcc'], '" tabindex="', $context['tabindex']++, '" size="40" />&nbsp;
  <a href="', $scripturl, '?action=findmember;input=bcc;quote=1;sesc=', $context['session_id'], '" onclick="return reqWin(this.href, 350, 400);"><img src="', $settings['images_url'], '/icons/assist.gif" alt="', $txt['find_members'], '" /></a> ', $txt[748], '
  </td>
  </tr>';
  // Subject of personal message.
  echo '
  <tr>
  <td align="right"><b', (isset($context['post_error']['no_subject']) ? ' style="color: red;"' : ''), '>', $txt[70], ':</b></td>
- <td><input type="text" name="subject" value="', $context['subject'], '" tabindex="', $context['tabindex']++, '" size="40" maxlength="50" /></td>
+ <td><input type="text" name="subject" placeholder="What does this message concern?" value="', $context['subject'], '" tabindex="', $context['tabindex']++, '" size="40" maxlength="50" /></td>
  </tr>';
 
  if ($context['visual_verification'])
  {
  echo '
  <tr>
  <td align="right" valign="top">
  <b>', $txt['pm_visual_verification_label'], ':</b>
  </td>
  <td>';
  if ($context['use_graphic_library'])
  echo '
  <img src="', $context['verificiation_image_href'], '" alt="', $txt['pm_visual_verification_desc'], '" /><br />';
  else
  echo '
  <img src="', $context['verificiation_image_href'], ';letter=1" alt="', $txt['pm_visual_verification_desc'], '" />

People get attached to how things look (I know I do), and it occurred to me that three empty <input> elements in a row might look a bit stark. I think including a placeholder attribute on the third <input> element will do a good job of balancing the issue this patch is meant to address (PMs without subjects) against imitating the way it looked originally (that is, two empty <input> elements, followed by a third one with something already in it). That way, it looks similar, but PMs will still fail to send unless you actually type something in.

Here's how it would look (I think the text I chose is short and to the point, but theymos can/should adjust it if he wants to):



Note to theymos: This new diff doesn't replace the earlier one, and they should be merged as a pair (the first diff stops the subject from being pre-populated with "(No subject)", and the second diff adds the placeholder attribute explained above).
6  Other / Meta / Improving the auto-linker (SMF patch) on: September 07, 2023, 03:26:56 PM
There was a recent Meta thread about the auto-linker sometimes failing to properly recognize URLs, and my name came up, so I decided to poke around and see if I could make sense of this bug.

As a recap, the auto-linker can sometimes be confused by leading spaces (particularly after a post has been edited, or quoted). For example, if you post the following (a sequence of URLs with an increasing amount of leading space, meant to showcase the problem):

Code:
www.thefarside.com
 www.thefarside.com
  www.thefarside.com
   www.thefarside.com
    www.thefarside.com
     www.thefarside.com
      www.thefarside.com
       www.thefarside.com

Then it'll (initially) render correctly, like this:



But after an edit (even one that doesn't change anything), it'll render incorrectly, like this (i.e. links with 2/4/6 leading spaces no longer recognized):



If the original post is quoted, then it'll render like this (i.e. links with 3/5/7 leading spaces no longer recognized):



(And if the quoted post were edited, it would revert to links with 2/4/6 leading spaces no longer being recognized.)

Pretty weird, huh?

Now, I know there are a few places in SMF where whitespace conversions happen (that's part of the reason I did the [nbsp] patch, so that non-breaking spaces could be used in a way that wouldn't be undone by those conversions). So, I don't find this bug that perplexing (though, I was surprised that the bug persisted even after bypassing preparsecode() and un_preparsecode(); I had figured that something in one of those two functions was behind spacing not "round-tripping" correctly on SMF).

Anyway, regardless of the ultimate source(s) of spacing getting silently messed with when you edit (or quote) a post, this particular bug is caused by the URL regexes in the auto-linker not properly taking this state of affairs into account (which is odd, because the e-mail regexes do). Specifically, the positive lookbehind assertions aren't aware of non-breaking spaces (and the second regex, the one for schemeless URLs, needs an additional tweak in order to prevent this bug from sometimes presenting during post preview).

Here's the diff for @theymos:

Code:
--- baseline/Sources/Subs.php	2011-09-17 21:59:55.000000000 +0000
+++ modified/Sources/Subs.php 2023-09-07 15:04:45.000000000 +0000
@@ -1820,36 +1820,39 @@
 
  // Don't go backwards.
  //!!! Don't think is the real solution....
  $lastAutoPos = isset($lastAutoPos) ? $lastAutoPos : 0;
  if ($pos < $lastAutoPos)
  $no_autolink_area = true;
  $lastAutoPos = $pos;
 
  if (!$no_autolink_area)
  {
  // Parse any URLs.... have to get rid of the @ problems some things cause... stupid email addresses.
  if (!isset($disabled['url']) && (strpos($data, '://') !== false || strpos($data, 'www.') !== false))
  {
  // Switch out quotes really quick because they can cause problems.
  $data = strtr($data, array('&#039;' => '\'', '&nbsp;' => $context['utf8'] ? "\xC2\xA0" : "\xA0", '&quot;' => '>">', '"' => '<"<', '&lt;' => '<lt<'));
 
+ // Can't make use of $non_breaking_space in the URL regexes (that definition won't work without the "u" modifier).
+ $nbsp = $context['utf8'] ? '\xc2\xa0' : '\xa0';
+
  // Only do this if the preg survives.
  if (is_string($result = preg_replace(array(
- '~(?<=[\s>\.(;\'"]|^)((?:http|https|ftp|ftps)://[\w\-_%@:|]+(?:\.[\w\-_%]+)*(?::\d+)?(?:/[\w\-_\~%\.@,\?&;=#(){}+:\'\\\\]*)*[/\w\-_\~%@\?;=#}\\\\])~i',
- '~(?<=[\s>(\'<]|^)(www(?:\.[\w\-_]+)+(?::\d+)?(?:/[\w\-_\~%\.@,\?&;=#(){}+:\'\\\\]*)*[/\w\-_\~%@\?;=#}\\\\])~i'
+ '~(?<=[\s>\.(;\'"]|' . $nbsp . '|^)((?:http|https|ftp|ftps)://[\w\-_%@:|]+(?:\.[\w\-_%]+)*(?::\d+)?(?:/[\w\-_\~%\.@,\?&;=#(){}+:\'\\\\]*)*[/\w\-_\~%@\?;=#}\\\\])~i',
+ '~(?<=[\s>(;\'<]|' . $nbsp . '|^)(www(?:\.[\w\-_]+)+(?::\d+)?(?:/[\w\-_\~%\.@,\?&;=#(){}+:\'\\\\]*)*[/\w\-_\~%@\?;=#}\\\\])~i'
  ), array(
  '[url]$1[/url]',
  '[url=http://$1]$1[/url]'
  ), $data)))
  $data = $result;
 
  $data = strtr($data, array('\'' => '&#039;', $context['utf8'] ? "\xC2\xA0" : "\xA0" => '&nbsp;', '>">' => '&quot;', '<"<' => '"', '<lt<' => '&lt;'));
  }
 
  // Next, emails...
  if (!isset($disabled['email']) && strpos($data, '@') !== false)
  {
  $data = preg_replace('~(?<=[\?\s' . $non_breaking_space . '\[\]()*\\\;>]|^)([\w\-\.]{1,80}@[\w\-]+\.[\w\-\.]+[\w\-])(?=[?,\s' . $non_breaking_space . '\[\]()*\\\]|$|<br />|&nbsp;|&gt;|&lt;|&quot;|&#039;|\.(?:\.|;|&nbsp;|\s|$|<br />))~' . ($context['utf8'] ? 'u' : ''), '[email]$1[/email]', $data);
  $data = preg_replace('~(?<=<br />)([\w\-\.]{1,80}@[\w\-]+\.[\w\-\.]+[\w\-])(?=[?\.,;\s' . $non_breaking_space . '\[\]()*\\\]|$|<br />|&nbsp;|&gt;|&lt;|&quot;|&#039;)~' . ($context['utf8'] ? 'u' : ''), '[email]$1[/email]', $data);
  }
  }

(Because this patch amounts to adjusting a pair of regexes in the BBCode parser, it will both fix this bug moving forward, and retroactively fix old posts that have unclickable links in them due to this issue, like this one.)
7  Other / Meta / Decoupling message IDs from topic IDs (SMF patch) on: August 23, 2023, 03:07:29 PM
This is something I've been working on since chatting with @TryNinja and @LoyceV about how they repair holes in their post archives after an outage. The way SMF combines topic IDs with message IDs makes it very awkward for scrapers to fill in gaps, because even though they know what ranges of message IDs they're missing, they often don't know what the corresponding topic IDs are.

Normally, the link format for a given (anchored) message, looks like this: /index.php?topic={topic_id}.msg{message_id}#msg{message_id}

This patch changes SMF so that it will also accept an asterisk [1] as the topic ID: /index.php?topic=*.msg{message_id}#msg{message_id}

Here's the diff for @theymos:

Code:
--- baseline/Sources/QueryString.php	2011-02-07 16:45:09.000000000 +0000
+++ modified/Sources/QueryString.php 2023-08-23 12:36:25.000000000 +0000
@@ -65,41 +65,41 @@
  - makes sure a string only contains character which are allowed in
    XML/XHTML (not 0-8, 11, 12, and 14-31.)
  - tries to handle UTF-8 properly, and shouldn't negatively affect
    character sets like ISO-8859-1.
  - does not effect keys, only changes values.
  - may call itself recursively if necessary.
 
  string ob_sessrewrite(string buffer)
  - rewrites the URLs outputted to have the session ID, if the user
    is not accepting cookies and is using a standard web browser.
  - handles rewriting URLs for the queryless URLs option.
  - can be turned off entirely by setting $scripturl to an empty
    string, ''. (it wouldn't work well like that anyway.)
  - because of bugs in certain builds of PHP, does not function in
    versions lower than 4.3.0 - please upgrade if this hurts you.
 */
 
 // Clean the request variables - add html entities to GET and slashes if magic_quotes_gpc is Off.
 function cleanRequest()
 {
- global $board, $topic, $boardurl, $scripturl, $modSettings;
+ global $board, $topic, $boardurl, $scripturl, $modSettings, $db_prefix;
 
  // Makes it easier to refer to things this way.
  $scripturl = $boardurl . '/index.php';
 
  // Save some memory.. (since we don't use these anyway.)
  unset($GLOBALS['HTTP_POST_VARS'], $GLOBALS['HTTP_POST_VARS']);
  unset($GLOBALS['HTTP_POST_FILES'], $GLOBALS['HTTP_POST_FILES']);
 
  // These keys shouldn't be set...ever.
  if (isset($_REQUEST['GLOBALS']) || isset($_COOKIE['GLOBALS']))
  die('Invalid request variable.');
 
  // Same goes for numeric keys.
  foreach (array_merge(array_keys($_POST), array_keys($_GET), array_keys($_FILES)) as $key)
  if (is_numeric($key))
  die('Invalid request variable.');
 
  // Numeric keys in cookies are less of a problem. Just unset those.
  foreach ($_COOKIE as $key => $value)
  if (is_numeric($key))
@@ -214,40 +214,49 @@
  else
  $board = 0;
 
  // If there's a threadid, it's probably an old YaBB SE link.  Flow with it.
  if (isset($_REQUEST['threadid']) && !isset($_REQUEST['topic']))
  $_REQUEST['topic'] = $_REQUEST['threadid'];
 
  // We've got topic!
  if (isset($_REQUEST['topic']))
  {
  // Make sure that its a string and not something else like an array
  $_REQUEST['topic'] = (string)$_REQUEST['topic'];
 
  // Slash means old, beta style, formatting.  That's okay though, the link should still work.
  if (strpos($_REQUEST['topic'], '/') !== false)
  list ($_REQUEST['topic'], $_REQUEST['start']) = explode('/', $_REQUEST['topic']);
  // Dots are useful and fun ;).  This is ?topic=1.15.
  elseif (strpos($_REQUEST['topic'], '.') !== false)
  list ($_REQUEST['topic'], $_REQUEST['start']) = explode('.', $_REQUEST['topic']);
 
+ // If a message ID was given with a topic ID of '*', then search for (and use) the correct topic ID.
+ if($_REQUEST['topic'] == '*' && !empty($_REQUEST['start']) && substr($_REQUEST['start'], 0, 3) == 'msg')
+ {
+ $result = db_query('SELECT ID_TOPIC FROM ' . $db_prefix . 'messages WHERE ID_MSG = ' . (int)substr($_REQUEST['start'], 3), __FILE__, __LINE__);
+ $row = mysql_fetch_row($result);
+ mysql_free_result($result);
+ $_REQUEST['topic'] = !empty($row) ? (int)$row[0] : -1;
+ }
+
  $topic = (int) $_REQUEST['topic'];
 
  // Now make sure the online log gets the right number.
  $_GET['topic'] = $topic;
  }
  else
  $topic = 0;
 
  // There should be a $_REQUEST['start'], some at least.  If you need to default to other than 0, use $_GET['start'].
  if (empty($_REQUEST['start']) || $_REQUEST['start'] < 0 || (int) $_REQUEST['start'] > 2147473647)
  $_REQUEST['start'] = 0;
 
  // The action needs to be a string and not an array or anything else
  if (isset($_REQUEST['action']))
  $_REQUEST['action'] = (string) $_REQUEST['action'];
  if (isset($_GET['action']))
  $_GET['action'] = (string) $_GET['action'];
 
  // Store the REMOTE_ADDR for later - even though we HOPE to never use it...
  $_SERVER['BAN_CHECK_IP'] = isset($_SERVER['REMOTE_ADDR']) && preg_match('~^((([1]?\d)?\d|2[0-4]\d|25[0-5])\.){3}(([1]?\d)?\d|2[0-4]\d|25[0-5])$~', $_SERVER['REMOTE_ADDR']) === 1 ? $_SERVER['REMOTE_ADDR'] : 'unknown';

(I made a previous attempt at this, based on HTTP redirects that would've meant a maximum of 30 requests per minute because of the rate-limiter. I'm hoping that this new redirection-free attempt meets with theymos' approval and ends up simplifying LoyceV's and TryNinja's scrapers and making them more reliable.)

[1] An asterisk (*) makes sense to me, and is safe to use according to my reading of RFC 3986, but zero (0) and underscore (_) both make sense too, I guess. (It's easy for theymos to adjust.)
8  Other / Meta / Cleaning up the [glow] tag (SMF patch) on: August 16, 2023, 05:44:58 PM
This one is pretty unexciting, but I like doing these seemingly-unimportant small improvements (they're quick to do and I believe that they add up over time).

The four problems with this tag (as I see it) are as follows:

1. When you click the "Glow" button on the toolbar, you get this: [glow=red,2,300][/glow]. I don't think red is a good default (mostly I see this tag used with yellow, Husires suggested changing the default last year, and I think that's a good suggestion).

2. The extra stuff after the color (,2,300) has to do with an ancient Microsoft-specific CSS filter, and the final parameter (,300) isn't actually wired up to anything.

3. The example on the help page is broken (because the pre-rendered HTML is based on the old Microsoft-specific CSS filter).

4. The toolbar button being green doesn't make much sense (maybe it did when this tag produced a true glow effect on Internet Explorer, but now all this tag does is set the background-color on a <span>, so it makes more sense for it to match whatever the default color is, IMO).

The patch below removes the extra parameters (without breaking old posts), sets the default to [glow=yellow][/glow], updates/repairs the example on the help page, and changes the icon so that it's yellow instead of green ( ).

Code:
--- baseline/Sources/Subs.php	2011-09-17 21:59:55.000000000 +0000
+++ modified/Sources/Subs.php 2023-08-16 15:41:43.000000000 +0000
@@ -1273,20 +1273,27 @@
  'after' => '</span>',
  ),
  array(
  'tag' => 'glow',
  'type' => 'unparsed_commas',
  'test' => '[#0-9a-zA-Z\-]{3,12},([012]\d{1,2}|\d{1,2})(,[^]]+)?\]',
  'before' => $context['browser']['is_ie'] ? '<table border="0" cellpadding="0" cellspacing="0" style="display: inline; vertical-align: middle; font: inherit;"><tr><td style="filter: Glow(color=$1, strength=$2); font: inherit;">' : '<span style="background-color: $1;">',
  'after' => $context['browser']['is_ie'] ? '</td></tr></table> ' : '</span>',
  ),
  array(
+ 'tag' => 'glow',
+ 'type' => 'unparsed_equals',
+ 'test' => '(#[\da-fA-F]{3}|#[\da-fA-F]{6}|[A-Za-z]{1,12})\]',
+ 'before' => '<span style="background-color: $1;">',
+ 'after' => '</span>',
+ ),
+ array(
  'tag' => 'hr',
  'type' => 'closed',
  'content' => '<hr />',
  'block_level' => true,
  ),
  array(
  'tag' => 'html',
  'type' => 'unparsed_content',
  'content' => '$1',
  'block_level' => true,

Code:
--- baseline/Themes/default/Post.template.php	2008-04-30 18:30:34.000000000 +0000
+++ modified/Themes/default/Post.template.php 2023-08-16 15:44:50.000000000 +0000
@@ -752,21 +752,21 @@
  // ]]></script>';
 
  // The below array makes it dead easy to add images to this page. Add it to the array and everything else is done for you!
  $context['bbc_tags'] = array();
  $context['bbc_tags'][] = array(
  'bold' => array('code' => 'b', 'before' => '[b]', 'after' => '[/b]', 'description' => $txt[253]),
  'italicize' => array('code' => 'i', 'before' => '[i]', 'after' => '[/i]', 'description' => $txt[254]),
  'underline' => array('code' => 'u', 'before' => '[u]', 'after' => '[/u]', 'description' => $txt[255]),
  'strike' => array('code' => 's', 'before' => '[s]', 'after' => '[/s]', 'description' => $txt[441]),
  array(),
- 'glow' => array('code' => 'glow', 'before' => '[glow=red,2,300]', 'after' => '[/glow]', 'description' => $txt[442]),
+ 'glow' => array('code' => 'glow', 'before' => '[glow=yellow]', 'after' => '[/glow]', 'description' => $txt[442]),
  'shadow' => array('code' => 'shadow', 'before' => '[shadow=red,left]', 'after' => '[/shadow]', 'description' => $txt[443]),
  'move' => array('code' => 'move', 'before' => '[move]', 'after' => '[/move]', 'description' => $txt[439]),
  array(),
  'pre' => array('code' => 'pre', 'before' => '[pre]', 'after' => '[/pre]', 'description' => $txt[444]),
  'left' => array('code' => 'left', 'before' => '[left]', 'after' => '[/left]', 'description' => $txt[445]),
  'center' => array('code' => 'center', 'before' => '[center]', 'after' => '[/center]', 'description' => $txt[256]),
  'right' => array('code' => 'right', 'before' => '[right]', 'after' => '[/right]', 'description' => $txt[446]),
  array(),
  'hr' => array('code' => 'hr', 'before' => '[hr]', 'description' => $txt[531]),
  array(),

Code:
--- baseline/Themes/default/Help.template.php	2006-12-01 03:25:52.000000000 +0000
+++ modified/Themes/default/Help.template.php 2023-08-16 15:48:08.000000000 +0000
@@ -1780,23 +1780,23 @@
  <td><img onmouseover="bbc_highlight(this, true);" onmouseout="bbc_highlight(this, false);" src="', $settings['images_url'], '/bbc/strike.gif" alt="', $txt['manual_posting_bbc_strike'], '" style="background-image: url(', $settings['images_url'], '/bbc/bbc_bg.gif); margin: 1px 2px 1px 1px;" /></td>
  <td>', $txt['manual_posting_strike_code'], '</td>
  <td><s>', $txt['manual_posting_strike_output'], '</s></td>
  <td>', $txt['manual_posting_strike_comment'], '</td>
  </tr>
  <tr>
  <td>', $txt['manual_posting_bbc_glow'], '</td>
  <td><img onmouseover="bbc_highlight(this, true);" onmouseout="bbc_highlight(this, false);" src="', $settings['images_url'], '/bbc/glow.gif" alt="', $txt['manual_posting_bbc_glow'], '" style="background-image: url(', $settings['images_url'], '/bbc/bbc_bg.gif); margin: 1px 2px 1px 1px;" /></td>
  <td>', $txt['manual_posting_glow_code'], '</td>
  <td>
- <div style="filter: Glow(color=red, strength=2); width: 30px;">
+ <span style="background-color: yellow;">
  ', $txt['manual_posting_glow_output'], '
- </div>
+ </span>
  </td>
  <td>', $txt['manual_posting_glow_comment'], '</td>
  </tr>
  <tr>
  <td>', $txt['manual_posting_bbc_shadow'], '</td>
  <td><img onmouseover="bbc_highlight(this, true);" onmouseout="bbc_highlight(this, false);" src="', $settings['images_url'], '/bbc/shadow.gif" alt="', $txt['manual_posting_bbc_shadow'], '" style="background-image: url(', $settings['images_url'], '/bbc/bbc_bg.gif); margin: 1px 2px 1px 1px;" /></td>
  <td>', $txt['manual_posting_shadow_code'], '</td>
  <td>
  <div style="filter: Shadow(color=red, direction=240); width: 30px;">
  ', $txt['manual_posting_shadow_output'], '

Code:
--- baseline/Themes/default/languages/Manual.english.php	2011-02-07 16:45:09.000000000 +0000
+++ modified/Themes/default/languages/Manual.english.php 2023-08-16 15:50:43.000000000 +0000
@@ -463,23 +463,23 @@
 $txt['manual_posting_italic_comment'] = '*';
 $txt['manual_posting_bbc_underline'] = 'Underline';
 $txt['manual_posting_underline_code'] = '[u]underline[/u]';
 $txt['manual_posting_underline_output'] = 'underline';
 $txt['manual_posting_underline_comment'] = '*';
 $txt['manual_posting_bbc_strike'] = 'Strikethrough';
 $txt['manual_posting_strike_code'] = '[s]strikethrough[/s]';
 $txt['manual_posting_strike_output'] = 'strikethrough';
 $txt['manual_posting_strike_comment'] = '*';
 $txt['manual_posting_bbc_glow'] = 'Glow';
-$txt['manual_posting_glow_code'] = '[glow=red,2,50]glow[/glow]';
+$txt['manual_posting_glow_code'] = '[glow=yellow]glow[/glow]';
 $txt['manual_posting_glow_output'] = 'glow';
-$txt['manual_posting_glow_comment'] = 'The three attributes (eg red, 2, 50) in the \'glow\' tag are color, strength and width respectively.';
+$txt['manual_posting_glow_comment'] = '*';
 $txt['manual_posting_bbc_shadow'] = 'Shadow';
 $txt['manual_posting_shadow_code'] = '[shadow=red,left]<br />shadow<br />[/shadow]';
 $txt['manual_posting_shadow_output'] = 'shadow';
 $txt['manual_posting_shadow_comment'] = 'The two attributes (eg red, left) in the \'shadow\' tag are color and direction respectively.';
 $txt['manual_posting_bbc_move'] = 'Marquee';
 $txt['manual_posting_move_code'] = '[move]move[/move]';
 $txt['manual_posting_move_output'] = 'move';
 $txt['manual_posting_move_comment'] = 'Not valid XHTML, but can also be used for images!';
 $txt['manual_posting_bbc_pre'] = 'Preformatted Text';
 $txt['manual_posting_pre_comment'] = 'Preserves critical text formatting, rendered in a monospace font.';

This is the command sequence I used to produce the yellow version of the toolbar icon from the original (the second command requires ImageMagick):

Code:
$ cp ./Themes/default/images/bbc/glow.gif ./Themes/default/images/bbc/glow_original.gif

Code:
$ convert-im6 ./Themes/default/images/bbc/glow_original.gif -color-matrix '0 1.33 0 0 1.33 0 0 0 0' ./Themes/default/images/bbc/glow.gif
9  Other / Meta / A concise 2FA/TOTP implementation (SMF patch) on: June 23, 2023, 04:28:25 PM
Hey, everybody! Wink

So, as some of you know, I've been working on adding (optional) 2FA to the forum for a while now. I mostly finished this work late last year, and it's just been sitting in a folder, waiting for its day in the sun. Cheesy

I've finally put the finishing touches to this one, and sent it off to theymos...

Most of my patches don't end up getting merged, and some of the time that's because of the difficulty in recasting diffs made against SMF 1.1.19 into a form suitable for the forum's customized version of SMF.

This time around I thought I'd try a different approach, so I put the bulk of the code in a new file: TOTP.php, and then included a small "example integration" of how this new file might be wired up to the rest of SMF.

The idea here is that rather than making all of the design decisions myself (e.g. how the settings UI should look/work, how it should interact with password resets, etc.) I've instead focused on giving theymos a bespoke set of 2FA primitives and a working example of how to use them.

This patch took around 90 hours to design, develop and test. I generally prefer to write code from scratch when I have the time, and that preference worked out very well for this project; I was able to whittle things down to a lot less (total) code than if I had pulled in any dependencies.

I know this post could use more info (and some images), but I expect a fair amount of iteration to happen based on theymos' feedback, so I'll describe the system in more detail once things firm up. In the meantime, if anyone has any questions about the implementation (as it currently stands), then I'm more than happy to answer them. Grin

On a more serious note, I find a lot of the dismissive attitude around 2FA to be quite confusing. When I'm digesting old topics about this issue (say, from before 2017, or so) the vibe I generally get from that group of users is that (optional) TOTP would be a really nice thing to have, full stop. But recently the sentiment seems to have switched, and instead of admitting that it would be a nice option to have, people seem very focused on picking it apart and reminding whoever posts about it that it's not a silver bullet, etc.

I'm not sure why the average opinion changed like that (maybe because 2FA on SMF became such an unlikely idea that people started "defending" the lack of it?), but I align with the more optimistic group and think that a correctly implemented TOTP option makes a ton of sense (even for SMF).

Beyond the obvious advantages for the people that enable it (like making their accounts all but impossible to "phish"), I'm hopeful that it might also help with incidents like this (which is what motivated me to roll up my sleeves in the first place).
10  Other / Meta / Thread banners (SMF idea) on: May 24, 2023, 09:30:39 AM
Hey, everyone! Smiley

I've noticed that on campaign threads, the manager often leaves important bits of information in a post that's sometimes a few pages back. For example, in my current campaign there are things worth knowing about, before applying:

If I catch you "bumping" your application or if you re-apply (while having left your application here some time in the last 2 weeks), you'll be put on ignore and forfeit any chance of joining this campaign in the future.

For now you can continue to apply if you are a prominent member/previous CM participant.

A lot of applicants are just flat-out ignoring those requests (more the second than the first), either because they find them inconvenient, or because they're not aware of them. Undecided

Instead of forging ahead on my own and making a patch, like I usually would, I thought this time around I'd try getting some feedback from the community before sitting down to code. Cheesy

What do you guys think of a feature that would let the OP of a topic specify some "banner" text that would appear at the top and bottom of every page of that thread (maybe in a colored box, or something)?

My thinking is that that would help with problems like the above, and would (over time) likely find a bunch of other use cases, too.
11  Other / Meta / Annoyed about a "bad" report, can the mod PM me? on: May 21, 2023, 02:31:49 AM
I recently had one of my reports marked as "bad", and it's irritating me more than I thought it would. Tongue

Because I'm still finding my feet with reporting, I'm careful to (mostly) only report things that are objectively against the rules, and don't contain much (or any) of a subjective component to them.

I'm not ruling out that I made a mistake, but I doubt it very much [1], and if I have then I'd like to learn from it, instead of being left in the dark.

To the mod that handled that report (should be easy to find, I've only made 197 reports so far, and this is the first bad one): please PM me. (I'll obviously keep our conversation private, I just want to know how to avoid this in the future.)

[1] Unless it's an older "unhandled" report that was revised; all my recent reports have been no-brainers, and I've carefully avoided the "old post" warning.
12  Other / Meta / Knave! Masquerader! Charlatan! (SMF easter egg) on: May 09, 2023, 06:54:33 AM
So, I found this piece of code while poking around SMF (testing the new [r] tag I'm working on):

Code:
// You are not!
if (isset($_POST['message']) && strtolower($_POST['message']) == 'i am the administrator.' && !$user_info['is_admin'])
fatal_error('Knave! Masquerader! Charlatan!', false);

Basically, if you try to post (or later edit) a message containing nothing but the (case-insensitive, unquoted) string "i am the administrator.", you'll (in all probability) be greeted with the following:



Anyway, just a small bit of fun that I thought would be worth sharing. Wink
13  Other / Meta / Making new messages more obvious (SMF patch) on: April 11, 2023, 08:55:35 AM
I've noticed that I check my PMs right after logging in, but then only haphazardly after that. The message counter in square brackets is pretty easy to miss (unless you remember to cast your eyes up there once in a while), so I sometimes leave messages sitting around for longer than I'd like.



I played around with a few ways to make it more obvious that your inbox needs attending. It's quite easy to mess up that area of the HTML, so what ended up working best was a carefully-rasterized image of a little white dot:



I think it catches the eye nicely without being too opinionated (stylistically), so it fits in with the rest of the SMF aesthetic.

Here's the patch for @theymos, if he approves of this change:

Code:
--- /var/www/baseline/Themes/default/index.template.php	2008-04-30 18:30:34.000000000 +0000
+++ /var/www/modified/Themes/default/index.template.php 2023-04-10 14:59:46.000000000 +0000
@@ -545,25 +545,26 @@
 
  // Edit Profile... [profile]
  if ($context['allow_edit_profile'])
  echo ($current_action == 'profile' || $context['browser']['is_ie4']) ? '<td class="maintab_active_' . $first . '">&nbsp;</td>' : '' , '
  <td valign="top" class="maintab_' , $current_action == 'profile' ? 'active_back' : 'back' , '">
  <a href="', $scripturl, '?action=profile">' , $txt[79] , '</a>
  </td>' , $current_action == 'profile' ? '<td class="maintab_active_' . $last . '">&nbsp;</td>' : '';
 
  // Go to PM center... [pm]
  if ($context['user']['is_logged'] && $context['allow_pm'])
  echo ($current_action == 'pm' || $context['browser']['is_ie4']) ? '<td class="maintab_active_' . $first . '">&nbsp;</td>' : '' , '
  <td valign="top" class="maintab_' , $current_action == 'pm' ? 'active_back' : 'back' , '">
- <a href="', $scripturl, '?action=pm">' , $txt['pm_short'] , ' ', $context['user']['unread_messages'] > 0 ? '[<strong>'. $context['user']['unread_messages'] . '</strong>]' : '' , '</a>
+ ' , $context['user']['unread_messages'] > 0 ? '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAQAAABuW59YAAAAJUlEQVR42mP4z4AMIZTL/2oghnLL/kNAGYT7Fsp9i5WLphjJKACA0kRWsKH5MQAAAABJRU5ErkJggg==" />&nbsp;' : '' ,
+ '<a href="', $scripturl, '?action=pm">' , $txt['pm_short'] , ' ', $context['user']['unread_messages'] > 0 ? '[<strong>'. $context['user']['unread_messages'] . '</strong>]' : '' , '</a>
  </td>' , $current_action == 'pm' ? '<td class="maintab_active_' . $last . '">&nbsp;</td>' : '';
 
  // The [calendar]!
  if ($context['allow_calendar'])
  echo ($current_action == 'calendar' || $context['browser']['is_ie4']) ? '<td class="maintab_active_' . $first . '">&nbsp;</td>' : '' , '
  <td valign="top" class="maintab_' , $current_action == 'calendar' ? 'active_back' : 'back' , '">
  <a href="', $scripturl, '?action=calendar">' , $txt['calendar24'] , '</a>
  </td>' , $current_action == 'calendar' ? '<td class="maintab_active_' . $last . '">&nbsp;</td>' : '';
 
  // the [member] list button
  if ($context['allow_memberlist'])
  echo ($current_action == 'mlist' || $context['browser']['is_ie4']) ? '<td class="maintab_active_' . $first . '">&nbsp;</td>' : '' , '
14  Other / Meta / Adding [nbsp] (non-breaking space) to the BBCode parser (SMF patch) on: February 20, 2023, 03:30:21 AM
A non-breaking space is a way to prevent a piece of text from "breaking" into multiple parts when there's not enough room for it to fit comfortably. This can be useful when formatting tables (I first proposed it here: Re: Gangs of BitcoinTalk). It can also be useful for signatures, too (e.g. @Royse777 could have used it recently when improving the ChipMixer signatures, [nbsp] would have worked better than [color=transparent].[/color] and could have allowed the design to remain the same; it wouldn't have been necessary to change the "{" into a "#").

Although using the Unicode character (U+00A0) directly is possible, it's much handier (and more "semantic") to make it available as a BBCode tag.

Here's a small patch for @theymos to add [nbsp] to this version of SMF:

Code:
--- /var/www/baseline/Sources/Subs.php	2011-09-17 21:59:55.000000000 +0000
+++ /var/www/modified/Sources/Subs.php 2023-02-20 02:20:01.000000000 +0000
@@ -1389,40 +1389,45 @@
  'after' => '</div>',
  'block_level' => true,
  ),
  array(
  'tag' => 'me',
  'type' => 'unparsed_equals',
  'before' => '<div class="meaction">* $1 ',
  'after' => '</div>',
  'quoted' => 'optional',
  'block_level' => true,
  'disabled_before' => '/me ',
  'disabled_after' => '<br />',
  ),
  array(
  'tag' => 'move',
  'before' => '<marquee>',
  'after' => '</marquee>',
  'block_level' => true,
  ),
+ array(
+ 'tag' => 'nbsp',
+ 'type' => 'closed',
+ 'content' => '&nbsp;',
+ ),
  array(
  'tag' => 'nobbc',
  'type' => 'unparsed_content',
  'content' => '$1',
  ),
  array(
  'tag' => 'pre',
  'before' => '<pre>',
  'after' => '</pre>',
  ),
  array(
  'tag' => 'php',
  'type' => 'unparsed_content',
  'content' => '<div class="phpcode">$1</div>',
  'validate' => isset($disabled['php']) ? null : create_function('&$tag, &$data, $disabled', '
  if (!isset($disabled[\'php\']))
  {
  $add_begin = substr(trim($data), 0, 5) != \'&lt;?\';
  $data = highlight_php_code($add_begin ? \'&lt;?php \' . $data . \'?&gt;\' : $data);
  if ($add_begin)
  $data = preg_replace(array(\'~^(.+?)&lt;\?.{0,40}?php(&nbsp;|\s)~\', \'~\?&gt;((?:</(font|span)>)*)$~\'), \'$1\', $data, 2);
15  Other / Meta / Result graphs for the 2022 BCA 📊 🏆 on: February 04, 2023, 11:55:57 PM
Now that the votes have been counted, I thought it would be worthwhile to have a more graphical representation of the results.

I made a graph for each of the 10 categories, as well as a final one with combined votes.

I figured this would take me maybe 45 minutes, but 6 hours later and I was still battling with matplotlib to produce that final graph. Cheesy

Anyway, I think the effort was worth it, and I hope that these are interesting for some of you to look at.











16  Other / Meta / Post history (action=profile;sa=showPosts) sometimes renders [code] too wide on: January 04, 2023, 08:43:43 AM
Anyone else notice that if you wrap something that contains really long lines with [code] tags, that it'll look okay in the actual post, but if you browse your post history afterwards, it'll render much wider than the surrounding posts and throw everything out?

Here's an example of what I mean: I recently posted some code that contained a few really long lines, here. If you look at that post, it renders without issue (i.e. that post doesn't render wider than the other posts in that thread). But, if you look (currently) at the first page of my post history, you'll see that the posts on that page are rendering strangely, making them difficult to read without lots of horizontal scrolling.

I messed around for a few (very frustrating) hours trying to get to the bottom of this, and managed to come up with the following SMF patch:

Code:
--- /var/www/baseline/Themes/default/Profile.template.php	2007-02-03 15:55:14.000000000 +0000
+++ /var/www/modified/Themes/default/Profile.template.php 2023-01-06 00:15:50.000000000 +0000
@@ -341,42 +341,45 @@
  if (!empty($context['posts']))
  {
  // Page numbers.
  echo '
  <tr class="catbg3">
  <td colspan="3">
  ', $txt[139], ': ', $context['page_index'], '
  </td>
  </tr>
  </table>';
 
  // Button shortcuts
  $quote_button = create_button('quote.gif', 145, 'smf240', 'align="middle"');
  $reply_button = create_button('reply_sm.gif', 146, 146, 'align="middle"');
  $remove_button = create_button('delete.gif', 121, 31, 'align="middle"');
  $notify_button = create_button('notify_sm.gif', 131, 125, 'align="middle"');
 
  // For every post to be displayed, give it its own subtable, and show the important details of the post.
  foreach ($context['posts'] as $post)
  {
+ // style="table-layout: fixed;" was added to each subtable as a partial fix for code sometimes rendering too wide (see https://bitcointalk.org/index.php?topic=5432954).
+ // This could also have been done further up the hierarchy, but this is a more natural place to apply the fix, and (surprisingly) produces slightly superior results, too.
+ // There's a second (progressive enhancement) part to this fix, after this foreach.
  echo '
- <table border="0" width="85%" cellspacing="1" cellpadding="0" class="bordercolor" align="center">
+ <table border="0" width="85%" cellspacing="1" cellpadding="0" class="bordercolor" align="center" style="table-layout: fixed;">
  <tr>
  <td width="100%">
  <table border="0" width="100%" cellspacing="0" cellpadding="4" class="bordercolor" align="center">
  <tr class="titlebg2">
  <td style="padding: 0 1ex;">
  ', $post['counter'], '
  </td>
  <td width="75%" class="middletext">
  &nbsp;<a href="', $scripturl, '#', $post['category']['id'], '">', $post['category']['name'], '</a> / <a href="', $scripturl, '?board=', $post['board']['id'], '.0">', $post['board']['name'], '</a> / <a href="', $scripturl, '?topic=', $post['topic'], '.', $post['start'], '#msg', $post['id'], '">', $post['subject'], '</a>
  </td>
  <td class="middletext" align="right" style="padding: 0 1ex; white-space: nowrap;">
  ', $txt[30], ': ', $post['time'], '
  </td>
  </tr>
  <tr>
  <td width="100%" height="80" colspan="3" valign="top" class="windowbg2">
  <div class="post">', $post['body'], '</div>
  </td>
  </tr>
  <tr>
@@ -391,40 +394,67 @@
  if ($post['can_reply'])
  echo '
  <a href="', $scripturl, '?action=post;topic=', $post['topic'], '.', $post['start'], '">', $reply_button, '</a>', $context['menu_separator'], '
  <a href="', $scripturl, '?action=post;topic=', $post['topic'], '.', $post['start'], ';quote=', $post['id'], ';sesc=', $context['session_id'], '">', $quote_button, '</a>';
  if ($post['can_reply'] && $post['can_mark_notify'])
  echo '
  ', $context['menu_separator'];
  if ($post['can_mark_notify'])
  echo '
  <a href="' . $scripturl . '?action=notify;topic=' . $post['topic'] . '.' . $post['start'] . '">' . $notify_button . '</a>';
 
  echo '
  </span></td>
  </tr>
  </table>
  </td>
  </tr>
  </table>';
  }
 
+ // This is the second part of the fix for code sometimes rendering too wide (the first part is at the top of the preceding foreach).
+ // The first part only prevents the problem from affecting surrounding posts, but when JavaScript is available, a more complete fix can be made.
+ // This code visits each eligible element and (defensively) sets the width to the same computed value.
+ // The width is computed once and then reused, not because of performance considerations (that's a nice consequence), but because that approach fixed some (rare) problem cases that came up in testing.
+ // The initial version of this code didn't account for the presence of <pre> elements being browser-dependent (that's what the "target" variable now does).
+ echo '
+ <script language="JavaScript" type="text/javascript"><!-- // --><![CDATA[
+ window.addEventListener("load", fix_wide_code_elements, false);
+ function fix_wide_code_elements() {
+ var all_code_elements = document.body.getElementsByClassName("code");
+ var same_computed_width = 0;
+ for(var i = 0; i < all_code_elements.length; i++) {
+ var element = all_code_elements[i];
+ if(element.tagName.toLowerCase() == "div") {
+ if(same_computed_width == 0) {
+ var target = element.firstElementChild && element.firstElementChild.tagName.toLowerCase() == "pre" ? element.firstElementChild : element;
+ element.style.width = "";
+ target.style.whiteSpace = "pre-wrap";
+ same_computed_width = element.clientWidth;
+ target.style.whiteSpace = "";
+ }
+ element.style.width = same_computed_width != 0 ? same_computed_width + "px" : "";
+ }
+ }
+ }
+ // ]]></script>';
+
  // Show more page numbers.
  echo '
  <table border="0" width="85%" cellspacing="1" cellpadding="4" class="bordercolor" align="center">
  <tr>
  <td colspan="3" class="catbg3">
  ', $txt[139], ': ', $context['page_index'], '
  </td>
  </tr>
  </table>';
  }
  // No posts? Just end the table with a informative message.
  else
  echo '
  <tr class="windowbg2">
  <td>
  ', $txt[170], '
  </td>
  </tr>
  </table>';
 }

Edit: Updated to account for browser differences pointed out by shahzadafzal.

@theymos: Please consider merging this fix, or applying your mind to the problem and coming up with something better. Thanks!



So, I ended up sinking more time into this problem and coming up with a (much) better patch... Smiley

(Thanks to PX-Z for the script they left here, those particular DOM modifications weren't the ones I ended up using, but, the final fix I arrived at was definitely influenced by that post.)

Code:
--- baseline/Themes/default/Profile.template.php	2007-02-03 15:55:14.000000000 +0000
+++ modified/Themes/default/Profile.template.php 2024-04-27 20:51:35.000000000 +0000
@@ -346,43 +346,48 @@
  <td colspan="3">
  ', $txt[139], ': ', $context['page_index'], '
  </td>
  </tr>
  </table>';
 
  // Button shortcuts
  $quote_button = create_button('quote.gif', 145, 'smf240', 'align="middle"');
  $reply_button = create_button('reply_sm.gif', 146, 146, 'align="middle"');
  $remove_button = create_button('delete.gif', 121, 31, 'align="middle"');
  $notify_button = create_button('notify_sm.gif', 131, 125, 'align="middle"');
 
+ // Controls whether or not an effort is made to prevent "wide" content from breaking the layout (e.g. code blocks containing really long lines).
+ $with_wide_fix = true;
+
  // For every post to be displayed, give it its own subtable, and show the important details of the post.
  foreach ($context['posts'] as $post)
  {
  echo '
  <table border="0" width="85%" cellspacing="1" cellpadding="0" class="bordercolor" align="center">
  <tr>
  <td width="100%">
  <table border="0" width="100%" cellspacing="0" cellpadding="4" class="bordercolor" align="center">
  <tr class="titlebg2">
  <td style="padding: 0 1ex;">
  ', $post['counter'], '
  </td>
  <td width="75%" class="middletext">
  &nbsp;<a href="', $scripturl, '#', $post['category']['id'], '">', $post['category']['name'], '</a> / <a href="', $scripturl, '?board=', $post['board']['id'], '.0">', $post['board']['name'], '</a> / <a href="', $scripturl, '?topic=', $post['topic'], '.', $post['start'], '#msg', $post['id'], '">', $post['subject'], '</a>
  </td>
  <td class="middletext" align="right" style="padding: 0 1ex; white-space: nowrap;">
  ', $txt[30], ': ', $post['time'], '
  </td>
- </tr>
+ </tr>', $with_wide_fix ? '
+ </table>
+ <table style="table-layout: fixed;" border="0" width="100%" cellspacing="0" cellpadding="4" class="bordercolor" align="center">' : '', '
  <tr>
  <td width="100%" height="80" colspan="3" valign="top" class="windowbg2">
  <div class="post">', $post['body'], '</div>
  </td>
  </tr>
  <tr>
  <td colspan="3" class="windowbg2" align="', !$context['right_to_left'] ? 'right' : 'left', '"><span class="middletext">';
 
  if ($post['can_delete'])
  echo '
  <a href="', $scripturl, '?action=profile;u=', $context['current_member'], ';sa=showPosts;start=', $context['start'], ';delete=', $post['id'], ';sesc=', $context['session_id'], '" onclick="return confirm(\'', $txt[154], '?\');">', $remove_button, '</a>';
  if ($post['can_delete'] && ($post['can_mark_notify'] || $post['can_reply']))

I also came up with a patch that fixes this problem when previewing PMs and when replying to PMs:

Code:
--- baseline/Themes/default/PersonalMessage.template.php	2006-12-01 15:43:03.000000000 +0000
+++ modified/Themes/default/PersonalMessage.template.php 2024-04-27 20:51:50.000000000 +0000
@@ -855,29 +855,32 @@
  <tr>
  <td class="windowbg">';
  foreach ($context['send_log']['sent'] as $log_entry)
  echo '<span style="color: green">', $log_entry, '</span><br />';
  foreach ($context['send_log']['failed'] as $log_entry)
  echo '<span style="color: red">', $log_entry, '</span><br />';
  echo '
  </td>
  </tr>
  </table><br />';
  }
 
+ // Controls whether or not an effort is made to prevent "wide" content from breaking the layout (e.g. code blocks containing really long lines).
+ $with_wide_fix = true;
+
  // Show the preview of the personal message.
  if (isset($context['preview_message']))
  echo '
  <br />
- <table border="0" width="80%" cellspacing="1" cellpadding="3" class="bordercolor" align="center">
+ <table ', $with_wide_fix ? 'style="table-layout: fixed;" ' : '', 'border="0" width="80%" cellspacing="1" cellpadding="3" class="bordercolor" align="center">
  <tr class="titlebg">
  <td>', $context['preview_subject'], '</td>
  </tr>
  <tr>
  <td class="windowbg">
  ', $context['preview_message'], '
  </td>
  </tr>
  </table><br />';
 
  // Main message editing box.
  echo '
@@ -985,30 +988,30 @@
  </table>';
 
  // Some hidden information is needed in order to make the spell checking work.
  if ($context['show_spellchecking'])
  echo '
  <form name="spell_form" id="spell_form" method="post" accept-charset="', $context['character_set'], '" target="spellWindow" action="', $scripturl, '?action=spellcheck"><input type="hidden" name="spellstring" value="" /></form>';
 
  // Show the message you're replying to.
  if ($context['reply'])
  echo '
  <br />
  <br />
- <table width="100%" border="0" cellspacing="1" cellpadding="4" class="bordercolor">
+ <table ', $with_wide_fix ? 'style="table-layout: fixed;" ' : '', 'width="100%" border="0" cellspacing="1" cellpadding="4" class="bordercolor">
  <tr>
  <td colspan="2" class="windowbg"><b>', $txt[319], ': ', $context['quoted_message']['subject'], '</b></td>
  </tr>
  <tr>
- <td class="windowbg2">
+ <td ', $with_wide_fix ? 'colspan="2" ' : '', 'class="windowbg2">
  <table width="100%" border="0" cellspacing="0" cellpadding="0">
  <tr>
  <td class="windowbg2">', $txt[318], ': ', $context['quoted_message']['member']['name'], '</td>
  <td class="windowbg2" align="right">', $txt[30], ': ', $context['quoted_message']['time'], '</td>
  </tr>
  </table>
  </td>
  </tr>
  <tr>
  <td colspan="2" class="windowbg">', $context['quoted_message']['body'], '</td>
  </tr>
  </table>';
17  Bitcoin / Development & Technical Discussion / [Code] Generating addresses from scratch (Python 3.6+) on: December 28, 2022, 07:22:40 AM
I thought it might be useful to have a completely self-contained Python script that generates Bitcoin addresses (both legacy P2PKH addresses, as well as bech32 P2WPKH addresses).

Most examples I've seen resort to using third-party packages, which makes it difficult for someone reading the code to follow (in detail) each of the steps involved. Even using Python's standard library has pitfalls, because the cryptographic hash functions included in Python are based on OpenSSL, which means that decisions coming from that project sometimes affect Python (e.g. some installations require additional configuration to make RIPEMD-160 available).

The following script uses no external libraries and makes no use of the cryptographic routines in the standard library, either. That way, this script should still run many years from now, and won't be affected by the activity of package maintainers, or even OpenSSL algorithm deprecations.

It's original code, written by me, specifically for this post. Whenever I consulted reference material, I attached the relevant link(s).

Code:
#!/usr/bin/env python3

# make_address.py v2022.12.27 (https://bitcointalk.org/index.php?topic=5432111)

from typing import List, Tuple, Callable

from functools import reduce

import secrets

import sys

show_testnet: bool = False

show_p2pkh_uncompressed: bool = False

show_p2pkh_compressed: bool = True

show_p2wpkh: bool = True

secp256k1_field_order: int = 2**256 - 0x1000003d1

secp256k1_group_order: int = 2**256 - 0x14551231950b75fc4402da1732fc9bebf

secp256k1_generator: Tuple[int, int] = (0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798, 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8)

u32: int = 0xffffffff

def fail_if(result: bool, message: str) -> None:

    if result:

        sys.exit('fatal error: ' + message)

def rotl32(x: int, s: int) -> int:

    # https://en.wikipedia.org/wiki/Circular_shift

    return (x << s | x >> (32 - s)) & u32

def ripemd160(x: bytes) -> bytes:

    # https://cacr.uwaterloo.ca/hac/about/chap9.pdf (algorithm 9.55, page 350) ['H1' -> 'h0', 'X' -> 'w']

    # this took some effort to derive from the above reference, I first implemented MD4 (from the same reference; algorithm 9.49, page 346) and then manipulated that into RIPEMD-160.

    yl: List[int] = [0] * 16 + [0x5a827999] * 16 + [0x6ed9eba1] * 16 + [0x8f1bbcdc] * 16 + [0xa953fd4e] * 16

    yr: List[int] = [0x50a28be6] * 16 + [0x5c4dd124] * 16 + [0x6d703ef3] * 16 + [0x7a6d76e9] * 16 + [0] * 16

    zl: List[int] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + [7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, 14, 11, 8] + [3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, 5, 12] + [1, 9, 11, 10, 0, 8, 12, 4, 13, 3, 7, 15, 14, 5, 6, 2] + [4, 0, 5, 9, 7, 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13]

    zr: List[int] = [5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12] + [6, 11, 3, 7, 0, 13, 5, 10, 14, 15, 8, 12, 4, 9, 1, 2] + [15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, 4, 13] + [8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, 13, 9, 7, 10, 14] + [12, 15, 10, 4, 1, 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11]

    sl: List[int] = [11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8] + [7, 6, 8, 13, 11, 9, 7, 15, 7, 12, 15, 9, 11, 7, 13, 12] + [11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, 12, 7, 5] + [11, 12, 14, 15, 14, 15, 9, 8, 9, 14, 5, 6, 8, 6, 5, 12] + [9, 15, 5, 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6]

    sr: List[int] = [8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6] + [9, 13, 15, 7, 12, 8, 9, 11, 7, 7, 12, 7, 6, 15, 13, 11] + [9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, 13, 13, 7, 5] + [15, 5, 8, 11, 14, 14, 6, 14, 6, 9, 12, 9, 12, 5, 15, 8] + [8, 5, 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11]

    fx: List[Callable[[int, int, int], int]] = [lambda u, v, w: u ^ v ^ w] * 16 + [lambda u, v, w: (u & v) | (~u & w)] * 16 + [lambda u, v, w: (u | ~v) ^ w] * 16 + [lambda u, v, w: (u & w) | (v & ~w)] * 16 + [lambda u, v, w: u ^ (v | ~w)] * 16

    padded: bytes = x + b'\x80' + b'\x00' * (64 - (len(x)+1+8) % 64) + (len(x) * 8).to_bytes(8, 'little')

    h0, h1, h2, h3, h4 = 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0

    for block_index in range(len(padded) // 64):

        block: bytes = padded[block_index*64:block_index*64+64]

        w: List[int] = [int.from_bytes(block[i*4:i*4+4], 'little') for i in range(16)]

        al, bl, cl, dl, el = h0, h1, h2, h3, h4

        ar, br, cr, dr, er = h0, h1, h2, h3, h4

        for j in range(80):

            tl = al + fx[j](bl, cl, dl) + w[zl[j]] + yl[j]

            tr = ar + fx[79-j](br, cr, dr) + w[zr[j]] + yr[j]

            al, bl, cl, dl, el = el, (el + rotl32(tl & u32, sl[j])) & u32, bl, rotl32(cl, 10), dl

            ar, br, cr, dr, er = er, (er + rotl32(tr & u32, sr[j])) & u32, br, rotl32(cr, 10), dr

        h0, h1, h2, h3, h4 = (h1 + cl + dr) & u32, (h2 + dl + er) & u32, (h3 + el + ar) & u32, (h4 + al + br) & u32, (h0 + bl + cr) & u32

    return h0.to_bytes(4, 'little') + h1.to_bytes(4, 'little') + h2.to_bytes(4, 'little') + h3.to_bytes(4, 'little') + h4.to_bytes(4, 'little')

def sha256(x: bytes) -> bytes:

    # https://en.wikipedia.org/wiki/SHA-2#Pseudocode

    # one thing that might catch (compared to the reference) is that I've replaced right rotations with left ones.

    k: List[int] = [0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2]

    padded: bytes = x + b'\x80' + b'\x00' * (64 - (len(x)+1+8) % 64) + (len(x) * 8).to_bytes(8, 'big')

    h0, h1, h2, h3, h4, h5, h6, h7 = 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19

    for block_index in range(len(padded) // 64):

        block: bytes = padded[block_index*64:block_index*64+64]

        w: List[int] = [int.from_bytes(block[i*4:i*4+4], 'big') for i in range(16)] + [0] * 48

        for i in range(16, 64):

            s0: int = rotl32(w[i-15], 25) ^ rotl32(w[i-15], 14) ^ (w[i-15] >> 3)

            s1: int = rotl32(w[i-2], 15) ^ rotl32(w[i-2], 13) ^ (w[i-2] >> 10)

            w[i] = (w[i-16] + s0 + w[i-7] + s1) & u32

        a, b, c, d, e, f, g, h = h0, h1, h2, h3, h4, h5, h6, h7

        for i in range(64):

            t1: int = (rotl32(e, 26) ^ rotl32(e, 21) ^ rotl32(e, 7)) + ((e & f) ^ (~e & g)) + h + w[i] + k[i]

            t2: int = (rotl32(a, 30) ^ rotl32(a, 19) ^ rotl32(a, 10)) + ((a & b) ^ (a & c) ^ (b & c))

            a, b, c, d, e, f, g, h = (t1 + t2) & u32, a, b, c, (d + t1) & u32, e, f, g

        h0, h1, h2, h3, h4, h5, h6, h7 = (h0 + a) & u32, (h1 + b) & u32, (h2 + c) & u32, (h3 + d) & u32, (h4 + e) & u32, (h5 + f) & u32, (h6 + g) & u32, (h7 + h) & u32

    return h0.to_bytes(4, 'big') + h1.to_bytes(4, 'big') + h2.to_bytes(4, 'big') + h3.to_bytes(4, 'big') + h4.to_bytes(4, 'big') + h5.to_bytes(4, 'big') + h6.to_bytes(4, 'big') + h7.to_bytes(4, 'big')

def rcp(x: int) -> int:

    # https://en.wikipedia.org/wiki/Modular_multiplicative_inverse#Using_Euler's_theorem

    # on 3.8+, use the much faster built-in modular inverse (exponent -1), otherwise use Euler's theorem.

    return pow(x, -1 if sys.hexversion >= 0x030800f0 else secp256k1_field_order-2, secp256k1_field_order)

def is_on_curve(point: Tuple[int, int]) -> bool:

    fail_if(point[0] < 0 or point[0] >= secp256k1_field_order, 'x component is out of range.')

    fail_if(point[1] < 0 or point[1] >= secp256k1_field_order, 'y component is out of range.')

    return (point[1]**2 - point[0]**3) % secp256k1_field_order == 7

def add(a: Tuple[int, int], b: Tuple[int, int]) -> Tuple[int, int]:

    # https://en.wikipedia.org/wiki/Elliptic_curve_point_multiplication#Point_addition

    # https://en.wikipedia.org/wiki/Elliptic_curve_point_multiplication#Point_doubling

    fail_if(not is_on_curve(a) and a != (0, 0), 'point is neither on the curve nor the identity point.')

    fail_if(not is_on_curve(b) and b != (0, 0), 'point is neither on the curve nor the identity point.')

    if b == (0, 0):

        return a

    if a == (0, 0):

        return b

    if b == (a[0], secp256k1_field_order - a[1]):

        return (0, 0)

    slope: int = (a[1] - b[1]) * rcp(a[0] - b[0]) if a != b else 3 * a[0]**2 * rcp(2 * a[1])

    x: int = slope**2 - a[0] - b[0]

    y: int = slope * (a[0] - x) - a[1]

    return (x % secp256k1_field_order, y % secp256k1_field_order)

def scale(point: Tuple[int, int], scalar: int) -> Tuple[int, int]:

    # https://en.wikipedia.org/wiki/Elliptic_curve_point_multiplication#Double-and-add

    fail_if(not is_on_curve(point), 'point is not on the curve.')

    fail_if(scalar < 1 or scalar >= secp256k1_group_order, 'scalar is out of range.')

    return point if scalar == 1 else add(scale(add(point, point), scalar // 2), (0, 0) if scalar % 2 == 0 else point)

def rebase_bytes(x: bytes, *, base: int, min_length: int = 0) -> List[int]:

    # this construction shows up in both base58 and bech32.

    value: int = int.from_bytes(x, 'big')

    result: List[int] = []

    while value != 0:

        value, remainder = divmod(value, base)

        result.append(remainder)

    return [0] * (min_length - len(result)) + result[::-1]

def base58_encode(x: bytes) -> str:

    # https://en.bitcoin.it/wiki/Base58Check_encoding

    return '1' * (len(x) - len(x.lstrip(b'\x00'))) + ''.join('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'[v] for v in rebase_bytes(x, base=58))

def wif_from_scalar(scalar: int, *, uncompressed: bool = False, testnet: bool = False) -> str:

    # https://en.bitcoin.it/wiki/Wallet_import_format

    fail_if(scalar < 1 or scalar >= secp256k1_group_order, 'scalar is out of range.')

    version: bytes = b'\xef' if testnet else b'\x80'

    payload: bytes = scalar.to_bytes(32, 'big') + (b'' if uncompressed else b'\x01')

    return base58_encode(version + payload + sha256(sha256(version + payload))[:4])

def p2pkh_from_point(point: Tuple[int, int], *, uncompressed: bool = False, testnet: bool = False) -> str:

    # https://en.bitcoin.it/wiki/Technical_background_of_version_1_Bitcoin_addresses

    # https://en.bitcoin.it/wiki/List_of_address_prefixes

    fail_if(not is_on_curve(point), 'point is not on the curve.')

    version: bytes = b'\x6f' if testnet else b'\x00'

    bytes_x: bytes = point[0].to_bytes(32, 'big')

    maybe_bytes_y: bytes = point[1].to_bytes(32, 'big') if uncompressed else b''

    prefix: bytes = b'\x04' if uncompressed else (b'\x02' if point[1] % 2 == 0 else b'\x03')

    payload: bytes = ripemd160(sha256(prefix + bytes_x + maybe_bytes_y))

    return base58_encode(version + payload + sha256(sha256(version + payload))[:4])

def bech32_checksum(x: List[int], *, testnet: bool = False) -> List[int]:

    # https://en.bitcoin.it/wiki/BIP_0173

    # this formulation bears little resemblance to the reference, but it fits my way of thinking better.

    expanded: List[int] = ([3, 3, 0, 20, 2] if testnet else [3, 3, 0, 2, 3]) + x + [0, 0, 0, 0, 0, 0]

    code: int = 1 ^ reduce(lambda a, b: (0x3b6a57b2 if a & (1 << 25) else 0) ^ (0x26508e6d if a & (1 << 26) else 0) ^ (0x1ea119fa if a & (1 << 27) else 0) ^ (0x3d4233dd if a & (1 << 28) else 0) ^ (0x2a1462b3 if a & (1 << 29) else 0) ^ ((a << 5 | b) & 0x3fffffff), expanded, 1)

    return [code >> 25, code >> 20 & 31, code >> 15 & 31, code >> 10 & 31, code >> 5 & 31, code & 31]

def bech32_encode(x: List[int]) -> str:

    # https://en.bitcoin.it/wiki/BIP_0173

    return ''.join('qpzry9x8gf2tvdw0s3jn54khce6mua7l'[v] for v in x)

def p2wpkh_from_point(point: Tuple[int, int], *, testnet: bool = False) -> str:

    # https://en.bitcoin.it/wiki/BIP_0173

    fail_if(not is_on_curve(point), 'point is not on the curve.')

    bytes_x: bytes = point[0].to_bytes(32, 'big')

    version: List[int] = [0]

    payload: List[int] = rebase_bytes(ripemd160(sha256((b'\x02' if point[1] % 2 == 0 else b'\x03') + bytes_x)), base=32, min_length=32)

    return ('tb' if testnet else 'bc') + '1' + bech32_encode(version + payload + bech32_checksum(version + payload, testnet=testnet))

def show_info(point: Tuple[int, int], scalar: int, *, p2pkh: bool = False, uncompressed: bool = False, testnet: bool = False) -> None:

    fail_if(not p2pkh and uncompressed, 'p2wpkh addresses are always compressed.')

    kind: str = ('Legacy' if p2pkh else 'Native SegWit') + ((', Uncompressed' if uncompressed else ', Compressed') if p2pkh else '') + (', Testnet' if testnet else '')

    address: str = p2pkh_from_point(point, uncompressed=uncompressed, testnet=testnet) if p2pkh else p2wpkh_from_point(point, testnet=testnet)

    private_key: str = (('' if uncompressed else 'p2pkh:') if p2pkh else 'p2wpkh:') + wif_from_scalar(scalar, uncompressed=uncompressed, testnet=testnet)

    print('       +------+' + '-' * len(kind) + '--+\n' + '       | Type | ' + kind + ' |\n    +--+------+' + '-' * len(kind) + '--+' + '-' * (len(address) - len(kind) - 1) + '+\n    | Address | ' + address + ' |\n+---+---------+' + '-' * len(address) + '--+' + '-' * (len(private_key) - len(address) - 1) + '+\n| Private Key | ' + private_key + ' |\n+-------------+' + '-' * len(private_key) + '--+')

def self_test() -> bool:

    # this function generates a test pattern consisting of 96 different addresses and 64 different WIFs, and then hashes the result to confirm its correctness.

    # the test pattern is slid 9170 places to slightly increase defect coverage (that offset includes some edge cases; ask me about it on Bitcointalk, if you're interested).

    pattern: str = ''

    for i in range(9171, 9171+16):

        scalar: int = (i * 0x9e3779b97f4a7c15f39cc0605cedc833477394a4b665b1d25e46d78ce158d565) % secp256k1_group_order

        point: Tuple[int, int] = scale(secp256k1_generator, scalar)

        pattern += wif_from_scalar(scalar, uncompressed=True, testnet=True) + wif_from_scalar(scalar, testnet=True) + wif_from_scalar(scalar, uncompressed=True) + wif_from_scalar(scalar)

        pattern += p2pkh_from_point(point, uncompressed=True, testnet=True) + p2pkh_from_point(point, testnet=True) + p2pkh_from_point(point, uncompressed=True) + p2pkh_from_point(point)

        pattern += p2wpkh_from_point(point, testnet=True) + p2wpkh_from_point(point)

    return base58_encode(ripemd160(pattern.encode('ascii'))) == '4RH6F51YXGjTEn5jnvxvFjmsN86j'

def main(args: List[str]) -> None:

    fail_if(len(args) not in { 0, 1 }, 'expected zero or one argument(s).')

    fail_if(len(args) == 1 and (len(args[0]) < 3 or args[0][:2].lower() != '0x' or any(c not in '0123456789abcdef' for c in args[0][2:].lower())), 'expected a private key in hexadecimal form.')

    fail_if(not self_test(), 'self-test failed.')

    scalar: int = secrets.randbits(256) if len(args) == 0 else int(args[0], 16)

    point: Tuple[int, int] = scale(secp256k1_generator, scalar)

    if show_p2pkh_uncompressed:

        show_info(point, scalar, p2pkh=True, uncompressed=True, testnet=show_testnet)

    if show_p2pkh_compressed:

        show_info(point, scalar, p2pkh=True, testnet=show_testnet)

    if show_p2wpkh:

        show_info(point, scalar, testnet=show_testnet)

if __name__ == '__main__':

    main(sys.argv[1:])

What does it do?

It generates Bitcoin addresses and WIFs for a given scalar. It's capable of producing 6 different address types (P2WPKH and compressed/uncompressed P2PKH, for both mainnet and testnet); there are boolean (True/False) variables at the top of the script that you can modify to your liking (show_testnet, show_p2pkh_uncompressed, show_p2pkh_compressed, and show_p2wpkh). By default, only show_p2pkh_compressed and show_p2wpkh are set to True, so when you run it you'll see output like the following:

       +------+--------------------+
       | Type | Legacy, Compressed |
    +--+------+--------------------+---------------+
    | Address | 1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH |
+---+---------+------------------------------------+-----------------------+
| Private Key | p2pkh:KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn |
+-------------+------------------------------------------------------------+
       +------+---------------+
       | Type | Native SegWit |
    +--+------+---------------+----------------------------+
    | Address | bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4 |
+---+---------+--------------------------------------------+----------------+
| Private Key | p2wpkh:KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn |
+-------------+-------------------------------------------------------------+


The above two addresses were generated from the scalar 0x1, like this:

Code:
$ python3 make_address.py 0x1

If you run it without arguments (that is, without supplying a scalar) it will use a randomly-generated 256-bit scalar, and could be used (for example) to make a cold wallet on an air-gapped computer.

How are you generating random scalars?

With the secrets module. Specifically, the secrets.randbits function. According to the Python documentation, that module will use "the most secure source of randomness that your operating system provides."

If you don't trust that module to do a good job, then you can supply your own scalar (in hexadecimal) as a command-line argument.

One way to randomly generate a 256-bit value would be to execute a command like:

Code:
$ dd if=/dev/urandom bs=1024 count=1 status=none | sha256sum -b --tag

Which will produce output that looks similar to this:

SHA256 (-) = 80aa0c48c65e9f2962e462c76c3e4f6d0c48a1e2a9359ecc47c8a12679f3ab98

Then you can take that value, prefix it with "0x" and supply it to the script, as follows:

Code:
$ python3 make_address.py 0x80aa0c48c65e9f2962e462c76c3e4f6d0c48a1e2a9359ecc47c8a12679f3ab98

Is this safe for me to use?

If your threat model doesn't include side-channel attacks, then I think it's pretty safe to use. I've done my best to ensure that this code won't produce a "bad" address (i.e. one that you won't be able to spend from later, because of a bug in my code). To make sure that possible behavior changes in future Python implementations won't silently lead to broken address generation, I've included a self-test that runs before the script does any further work. In the event that something about your version of Python or your system leads to incorrect addresses being generated, the script will fail with "fatal error: self-test failed."

I'm a very careful programmer, but I wouldn't consider myself an expert at finite fields, elliptic curves or hash functions, so although I've tested this code extensively, it's quite possible that something may have eluded me. Please keep that in mind before risking significant funds on an address generated by this script. I'm probably being overcautious, but I would hate to be responsible for anyone losing any of their precious sats.

Do you want feedback on your code?

Legitimate bug reports are greatly appreciated! Style tips, micro-optimizations (replacing % 64 with & 63, etc.) and the like, not so much.

This code is not meant to be fast, pretty or idiomatic; it's only meant to be correct, and to map reasonably well to the included reference links.

If you spot a real bug (like a case I've mishandled in the EC point addition code, for example) I'll send you some merit. Please include a small test case, demonstrating how the bug leads to incorrect address generation.

What's with all the type annotations?

I used mypy as a static type checker to minimize bugs and to help me reason about the correctness of the code. The annotations themselves are inert (they don't affect execution) and don't require anything beyond vanilla Python being installed. You can remove them if you like, although I wouldn't recommend it.

If you have mypy installed, then you can type check the code yourself, with:

Code:
$ mypy --strict make_address.py
18  Bitcoin / Development & Technical Discussion / [Code] Modular multiplicative inverses (Python 3.8+) on: December 19, 2022, 04:39:50 AM
I've been playing around with generating Bitcoin addresses from scratch in Python (will post about that soon) and ran into something interesting to do with calculating modular multiplicative inverses. Until recently, I'd only bumped into two ways of doing that, either using the extended Euclidean algorithm, or using Euler's theorem:

Method A (extended Euclidean algorithm)

Code:
def invert_a(x: int, m: int) -> int:
    # reference: https://en.wikipedia.org/wiki/Extended_Euclidean_algorithm#Pseudocode
    r0, r1 = x, m
    s0, s1 = 1, 0
    while r1 != 0:
        quotient = r0 // r1
        r0, r1 = r1, r0 - quotient * r1
        s0, s1 = s1, s0 - quotient * s1
    if r0 != 1:
        raise ValueError('x is not invertible for the given modulus')
    return s0 if s0 >= 0 else s0 + m

Method B (exponentiation with m-2, Euler's theorem)

Code:
def invert_b(x: int, m: int) -> int:
    # reference: https://en.wikipedia.org/wiki/Modular_multiplicative_inverse#Using_Euler's_theorem
    return pow(x, m-2, m)

Since Python 3.8, you can pass negative exponents into the pow function, leading to two more ways:

Method C (exponentiation with -1)

Code:
def invert_c(x: int, m: int) -> int:
    return pow(x, -1, m)

Method D (exponentiation with -m)

Code:
def invert_d(x: int, m: int) -> int:
    return pow(x, -m, m)

Python is not really meant for performance, but I figured I'd benchmark all four methods out of curiosity. After testing that they all produce identical answers (for x >= 1 and x < m, with the moduli that I care about: p and n from secp256k1), I inverted 100,000 private keys 10 separate times with each method and then took the mean:

+---------------+-------+
| CPython 3.9.2 | Speed |
+----+----------+-------+
     | Method A | 3.85x | |||||||||||||||||||||||
     +----------+-------+
     | Method B | 1.15x | |||||||
     +----------+-------+
     | Method C | 7.80x | |||||||||||||||||||||||||||||||||||||||||||||||
     +----------+-------+
     | Method D | 1.00x | ||||||
     +----------+-------+


I was surprised that method A held up so well, considering that the other methods consist only of a single call to pow and therefore do almost all of their work in native code.

Why is method C the fastest? Peeking at the relevant CPython code (inside the implementation of pow: here), it looks like it's handling negative exponents with an internal implementation of the extended Euclidean algorithm (this one: here). I was puzzled at first by method D being so much slower than method C (I expected it to be slower, just not 7.8x) but then realized that although it does make use of the same internal special-casing that makes method C so fast, it then follows that with an expensive (especially for big values of m) modular exponentiation that isn't special-cased. To be clear, method C (internally) goes the same way (the pow function inverts the base and negates the exponent before proceeding), but with 1 as the exponent the rest of the calculation terminates quickly.

If you're on Python 3.8+, use method C, it's both the fastest and the cleanest (IMHO). If you're stuck on an older version of Python and need more performance, prefer method A over method B. I haven't been able to think of a good use case for method D.

Note that none of the above methods are safe to use with respect to side-channel attacks; they all leak information via small argument-dependent differences in execution time and/or power consumption. That's a very academic threat for most users though, so don't let that stop you from learning/experimenting. If you're writing a professional wallet, then you'll have to step up to more advanced techniques.
19  Other / Meta / Revised — Incentivizing moderation reports with millimerits on: December 04, 2022, 01:31:23 PM
Original proposal

I saw a post by Welsh saying that moderation reports have been in decline and that he misses the days when he woke up to hundreds of reports in his queue. It got me thinking of ways to jump-start it again.

So, the idea would be for each user to have a "millimerit" balance (either hidden from view, or maybe visible next to the existing report accuracy numbers). Moderators could then award members with say 25 millimerits for any good report and -100 for any bad report.

The balance can (maybe, not sure) go negative, so you would have to "work off" each of your bad reports to get back above zero and each time you accumulate 1000 millimerits (40 good reports) the balance gets reset and you're awarded one real merit.

The thing I like about this system is that it can be completely ignored by the reporters that don't need an incentive (they can just pretend the system doesn't exist and keep doing their thing; it changes nothing for them except that they'll receive an extra merit once in a while). But, it might encourage others to spend their time cleaning up the forum, too. The steep carrot/stick ratio is to prevent would-be abusers from gaining any traction.

The specific values will need discussion/tuning so that there's just the right amount of incentive; too little and it obviously won't jump-start anything, too much and it would likely lead to excessive reporting.

An interesting consequence of a system like this is that it would probably compel a decent fraction of merit-seeking newbies to thoroughly learn the forum's rules.

This proposal touches code that I don't have access to, so I'm unable to provide a patch like I normally would, but it's a nice idea and I thought it would be worth sharing.

Revised proposal

Thanks for sharing your thoughts, everyone!

It seems like maybe reporter badges would be a better (less abusable) motivator. I'll address reporter badges at the end of this edit.

People have pointed out that reports are down because activity is down in general. That's true, but I still think ideas to motivate reporters are worth discussing.

The system as proposed above would have been automatic (i.e. once a mod marked a report as good or bad, the millimerits would be sent). The -100 penalty for a bad report was there to prevent people from spamming the mods with reports; each bad report "undoing" 4 good reports means that the threshold for profitability is 80% accuracy (i.e. at or below 80% you can't sustainably earn millimerits).

One piece of feedback that torpedoes the original proposal is the possibility of collusion, which I didn't think about. If the reward is handed out automatically, then it's only a matter of time before people start manufacturing posts for others to "report".

I think both of the above problems (the -100 penalty and abuse by collusion) can be solved with a fairly small adjustment:

Get rid of the -100 penalty for a bad report, but instead of the 25 millimerits for a good report being sent automatically, leave it up to the individual moderator's discretion. That way, the mods can spot abuse themselves; if someone spams them with mostly inaccurate reports or if reports seem suspiciously like collusion (e.g. someone keeps reporting new posts from the same user over and over again) then they can decline to issue any millimerits. Maybe the rule could be that when reporting a post, the moderator that handles it has the option of sending you between 0 and 1000 millimerits depending on how useful/valuable your report was. Then (just making up numbers) original reports can get ~60 and redundant (but still good) reports can get ~20, really valuable reports like malware can get ~300, and so on (some moderators would likely be more generous than others).

Reporter badges

A few people mentioned reporter badges, which I think would be a nice addition to the forum!

But I would like to remind the concept of (Seeking reporter badge images)
Maybe OP can do the heavy lifting for theymos to implement the reporter badges? That would be really cool.
Imho, way more elegant solution to make people report more is to introduce some sort of badges for the most active reporters as that's what people asked for repeatedly.
I was wondering if Gloves could possibly get those patches? I mean, for the images?
Reporters could be recognized with something like the proposed reporters badge of many years ago
Slow down, we reporters have yet to receive our badge JPEGs promised aeons ago. Cry
However, like adding badges to the profile, there are a lot of option to grow.
A reporter badge would likely be more effective there.
I'm still hopeful that they get implemented at some point.
I support this also.
Well, it's been already 4 years and counting.
Which brings back the question when those badges will be implemented? Cheesy

I'm for the idea of reporter badges, and I would be willing to spend my time on implementing them (including the ability for people to opt out, if they like). However, I've offered to take things off of theymos' plate before (specifically, this) and never heard back from him, so I'm not hopeful that he would let me tackle this.
20  Other / Meta / Should the difference in size between normal and quoted text be smaller? on: November 27, 2022, 10:53:37 AM
I've noticed that my eyes often want to quickly skim quoted text (which is a bit smaller) compared to unquoted text and it sometimes takes an effort for me to read it properly. I browse the forum from two different computers with two different monitor resolutions (one a fair bit higher than the other) and I recently (~1 year ago) had my eyes tested (grateful to not need glasses yet; must be all those carrots -- thanks, mom!) so although I haven't ruled it out, I don't think it's me or my setup.

I'm aware that I can fix this for myself by making things bigger with browser settings and whatnot, but while I was poking around the forum's CSS I found what I think is behind my problem: normal text is set to 13px, quoted text is set to 11px and quote titles are set to 10px. Now, quoted text should appear smaller than normal text, and quote titles should appear smaller than quoted text (ignoring boldness), so I'm not proposing making them all the same size (that looks really goofy, I tried it) but I think making it go 13px/12px/11px is better than 13px/11px/10px.

I think this is more important than it might seem, because I believe it subtly affects how people read posts (i.e. subconsciously avoiding carefully reading quoted text, which is often as -- and sometimes more -- important than unquoted text in terms of fully understanding a given post).

Here's what 13px/11px/10px (how it is now) looks like:



And here's what 13px/12px/11px (what I'm proposing) looks like:



Anybody else prefer the second one?

Edit: The above screenshots are a little hard to accurately gauge the difference on because of font hinting and the like, so take them with a grain of salt (i.e. the same stylesheet adjustments will likely look a bit different on your system).

Not that theymos needs it (it's a trivial change), but for completeness' sake, here's the stylesheet patch:

Code:
--- ./style_before.css	2022-11-27 10:03:55.091702000 +0000
+++ ./style_after.css 2022-11-27 10:06:29.647700841 +0000
@@ -120,25 +120,25 @@
 {
  border: 0;
 }
 
 /* A quote, perhaps from another post. */
 .quote
 {
  color: #000000;
  background-color: #f1f2f4;
  border: 1px solid #d0d0e0;
  padding: 5px;
  margin: 1px 3px 6px 6px;
- font-size: 11px;
+ font-size: 12px;
  line-height: 1.4em;
 }
 
 
 /* A code block - maybe even PHP ;). */
 .code
 {
  color: #000000;
  background-color: #fff;
  font-family: "courier new", "times new roman", monospace;
  font-size: 12px;
  line-height: 1.3em;
@@ -152,25 +152,25 @@
  overflow: auto;
  /* Stop after about 24 lines, and just show a scrollbar. */
  max-height: 24em;
 }
 
 /* The "Quote:" and "Code:" header parts... */
 .quoteheader, .codeheader
 {
  color: #476C8E;
  text-decoration: none;
  font-style: normal;
  font-weight: bold;
- font-size: 10px;
+ font-size: 11px;
  line-height: 1.2em;
  margin-left: 6px;
 }
 
 /* Generally, those [?] icons.  This makes your cursor a help icon. */
 .help
 {
  cursor: help;
 }
 
 /* /me uses this a lot. (emote, try typing /me in a post.) */
 .meaction
Pages: [1] 2
Powered by MySQL Powered by PHP Powered by SMF 1.1.19 | SMF © 2006-2009, Simple Machines Valid XHTML 1.0! Valid CSS!