Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions include/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
#include "config_defaults.h"

/* Project Metadata */
#define TNT_VERSION "1.6.0"
#define TNT_VERSION "1.7.0"

/* Public process/exec exit statuses. TNT follows the common sysexits(3)
* convention for usage errors while keeping runtime failures portable. */
Expand Down Expand Up @@ -84,8 +84,8 @@ void buffer_appendf(char *buffer, size_t buf_size, size_t *pos,
* non-numeric, or out of range. */
int env_int(const char *name, int fallback, int min_val, int max_val);

/* Reject usernames containing shell metacharacters, control characters, or
* a leading space/dot/dash. Used by username read, exec post (SSH login as
/* Reject usernames containing shell metacharacters, control characters, a
* check mark (the verified sign), or a leading space/dot/dash. Used by username read, exec post (SSH login as
* author), and the :nick command. */
bool is_valid_username(const char *username);

Expand Down
9 changes: 9 additions & 0 deletions include/history_view.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ int history_view_height(int terminal_height, int input_rows);
* messages fall off the bottom of a full screen. */
void history_view_message_time(const message_t *msg, char out[16]);
int history_view_message_prefix_width(const message_t *msg);

/* A verified sender carries a check mark after its name. For a verified
* action the actor's name moves into the prefix with the mark, so this is
* the length of that name in the content, or 0 when nothing moves. */
#define HISTORY_VIEW_VERIFIED_MARK "✓"
size_t history_view_action_actor_len(const message_t *msg);

/* The part of the content that wraps after the prefix. */
const char *history_view_message_body(const message_t *msg);
int history_view_message_content_width(const message_t *msg, int width);

/* Display rows one message occupies at the given render width. Always at
Expand Down
15 changes: 14 additions & 1 deletion include/identity.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

typedef struct {
char handle[TNT_IDENTITY_HANDLE_MAX + 1];
char skeleton[TNT_IDENTITY_HANDLE_MAX + 1];
int key_count;
char keys[TNT_IDENTITY_MAX_KEYS][TNT_IDENTITY_FINGERPRINT_SIZE];
} identity_entry_t;
Expand All @@ -24,6 +25,14 @@ typedef struct {
} identity_table_t;

bool identity_handle_valid(const char *handle);

/* The form two names share when they look alike: invisible characters and
* `_ - .` and space removed, fullwidth forms and common Cyrillic, Greek and
* Latin look-alikes mapped to ASCII, lowercased, then 0 1 i | read as o l l l
* and rn, vv as m, w. The m1ngsama ID server computes the same skeleton, so
* the two must change together. Bytes that are not valid UTF-8 are kept as
* they are. The skeleton is never longer than `name`. */
void identity_skeleton(const char *name, char *out, size_t out_size);
bool identity_fingerprint_valid(const char *fingerprint);

/* Format `key` the way `ssh-keygen -l` does: "SHA256:" and unpadded base64. */
Expand All @@ -41,6 +50,9 @@ void identity_table_free(identity_table_t *table);
/* Match `name` against handles without regard to ASCII letter case. */
const identity_entry_t *identity_table_find(const identity_table_t *table,
const char *name);
/* Match `name` against handles by skeleton. */
const identity_entry_t *identity_table_find_lookalike(
const identity_table_t *table, const char *name);
const char *identity_table_handle_for_key(const identity_table_t *table,
const char *fingerprint);

Expand All @@ -52,7 +64,8 @@ bool identity_table_forget_key(identity_table_t *table,
const char *fingerprint);

/* The process-wide identities file in the state directory. Each call
* reloads the file when it changed on disk. */
* reloads the file when it changed on disk. A name is reserved when it
* looks like a handle, not only when it equals one. */
bool identity_is_reserved(const char *name);
bool identity_handle_for_key(const char *fingerprint, char *handle,
size_t handle_size);
Expand Down
3 changes: 3 additions & 0 deletions include/input.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,8 @@ void notify_mentions(const char *content, const client_t *sender);
* Each returns false when no interactive session has that id. */
bool input_identity_notice(const char *session, const char *plain_text);
bool input_identity_verified(const char *session, const char *handle);
/* Reserve `handle` without a key and rename every unverified session whose
* name looks like it. */
int input_identity_reserve(const char *handle);

#endif /* INPUT_H */
4 changes: 3 additions & 1 deletion include/message.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ typedef struct {
time_t timestamp;
char username[MAX_USERNAME_LEN];
char content[MAX_MESSAGE_LEN];
bool verified; /* The sender proved its identity */
/* Cached server-local display fields. A room message is formatted once
* when inserted instead of making every connected TUI contend on the
* platform timezone lock for every repaint. */
Expand All @@ -34,7 +35,8 @@ void message_format(const message_t *msg, char *buffer, size_t buf_size, int wid
* Returns the last max_results matches in chronological order; caller must free *results. */
int message_search(const char *query, message_t **results, int max_results);

/* Export valid persisted log records in messages.log v1 format. max_records
/* Export valid persisted log records without their flags field, as exec dump
* always has. max_records
* 0 exports all valid records; positive values export the last max_records
* valid records. Caller must free *output. */
int message_dump_text(char **output, size_t *output_len, int max_records);
Expand Down
56 changes: 33 additions & 23 deletions include/message_log.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@
void message_log_format_timestamp_utc(time_t ts, char *buffer,
size_t buf_size);

/* First line of a migrated log. Both the old and the new parser treat it as
* an unparseable record and skip it, which is what makes it safe to add to a
* file an older binary may still read. */
#define MESSAGE_LOG_HEADER "#tnt-message-log v2"
/* First line of a log in the current format. Every parser treats a marker
* line as an unparseable record and skips it. */
#define MESSAGE_LOG_VERSION 3
#define MESSAGE_LOG_HEADER "#tnt-message-log v3"
#define MESSAGE_LOG_FLAG_VERIFIED 'v'

bool message_log_is_header(const char *line);
/* The version a marker line names (2 or 3), or 0 when the line is not a
* marker. A file without a marker is version 1. */
int message_log_header_version(const char *line);

/* Content is stored escaped so a message may contain a newline while records
* stay one per line: `\` becomes `\\`, a newline becomes `\n`. */
Expand All @@ -27,27 +30,34 @@ size_t message_log_encoded_length(const char *in);
* a newline, and nothing else below 0x20, no DEL, no C1. */
bool message_log_content_has_forbidden_control(const char *s);

/* Rewrite one pre-v2 log line with its content field escaped, preserving the
* line's own terminator. A line that is not a record is copied through: the
* parser already skips it. False means the record cannot be represented in
* v2, so it is dropped rather than rewritten to decode as something else. */
bool message_log_upgrade_record(const char *line, char *out, size_t out_size);

/* Bring a pre-v2 log up to the escaped format in place: back it up to
* `<path>.v1.bak`, escape every content field, and prepend the header. A
* missing file and a file that already carries the header are both no-ops,
* which is what makes this safe to run at every start. Returns -1 only when
* the log was left untouched by a failure. */
/* Rewrite one line of a version `from_version` log as a current record,
* preserving the line's own terminator: a v1 content field is escaped, and
* every record gains an empty flags field. A line that is not a record is
* copied through: the parser already skips it. False means the record
* cannot be represented, so it is dropped rather than rewritten to decode as
* something else. */
bool message_log_upgrade_record(const char *line, int from_version, char *out,
size_t out_size);

/* Bring an older log up to the current format in place: back it up to
* `<path>.v1.bak` or `<path>.v2.bak`, upgrade every record, and prepend the
* current header. A missing file and a file that already carries the
* current header are both no-ops, which is what makes this safe to run at
* every start. Returns -1 only when the log was left untouched by a
* failure. */
int message_log_migrate(const char *path);

/* Parse one complete messages.log record and decode its content. `now` is
* used to reject records outside TNT's accepted replay window. */
/* Parse one complete current record and decode its content. `now` is used
* to reject records outside TNT's accepted replay window. */
bool message_log_parse_record(const char *line, message_t *out, time_t now);

/* Format one messages.log record, escaping its content. record_len receives the number of bytes
* that would be written, excluding the trailing NUL. Passing NULL/0 for the
* output buffer is allowed when only the length is needed. */
int message_log_format_record(const message_t *msg, char *buffer,
size_t buf_size, size_t *record_len);
/* Format one record, escaping its content. Without `with_flags` the record
* stops after the content field, the form exec dump has always written.
* record_len receives the number of bytes that would be written, excluding
* the trailing NUL. Passing NULL/0 for the output buffer is allowed when
* only the length is needed. */
int message_log_format_record(const message_t *msg, bool with_flags,
char *buffer, size_t buf_size,
size_t *record_len);

#endif /* MESSAGE_LOG_H */
2 changes: 1 addition & 1 deletion include/module_protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos,

int tnt_module_append_message_created(char *buffer, size_t buf_size,
size_t *pos, const char *message_id,
const message_t *msg, bool verified);
const message_t *msg);

int tnt_module_append_presence(char *buffer, size_t buf_size, size_t *pos,
const char *type, const char *nickname,
Expand Down
3 changes: 1 addition & 2 deletions include/module_runtime.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ void tnt_module_runtime_shutdown(void);

/* Queue a user/core-created public message for enabled modules. This is
* intentionally fire-and-forget so basic chat never depends on module health. */
void tnt_module_runtime_publish_message_created(const message_t *msg,
bool verified);
void tnt_module_runtime_publish_message_created(const message_t *msg);

/* Track an interactive user joining or leaving and notify presence:read
* modules. Safe to call with the room lock held. */
Expand Down
1 change: 1 addition & 0 deletions include/theme.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ typedef struct {
const char *accent_bold; /* bold accent, e.g. "\033[1;36m" */
const char *accent_dim; /* dim accent, e.g. "\033[2;36m" */
const char *accent_italic; /* italic accent, e.g. "\033[3;36m" */
const char *verified; /* verified mark, never the accent colour */
} theme_t;

/* Number of registered themes. */
Expand Down
4 changes: 2 additions & 2 deletions packaging/arch/.SRCINFO
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
pkgbase = tnt-chat
pkgdesc = SSH-native terminal chat server with a Vim-style interface
pkgver = 1.6.0
pkgver = 1.7.0
pkgrel = 1
url = https://github.com/m1ngsama/TNT
arch = x86_64
Expand All @@ -10,7 +10,7 @@ pkgbase = tnt-chat
makedepends = make
depends = libssh
depends = openssh
source = tnt-chat-v1.6.0-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.6.0/tnt-chat-v1.6.0-source.tar.gz
source = tnt-chat-v1.7.0-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.7.0/tnt-chat-v1.7.0-source.tar.gz
source = tnt-chat.sysusers
sha256sums = SKIP
sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed
Expand Down
2 changes: 1 addition & 1 deletion packaging/arch/PKGBUILD
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Maintainer: M1ng <contact@m1ng.space>

pkgname=tnt-chat
pkgver=1.6.0
pkgver=1.7.0
pkgrel=1
pkgdesc='SSH-native terminal chat server with a Vim-style interface'
arch=('x86_64' 'aarch64')
Expand Down
11 changes: 11 additions & 0 deletions packaging/debian/debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
tnt-chat (1.7.0-1) unstable; urgency=medium

* Treat a name that looks like a reserved handle as reserved, and rename
unverified sessions using one when identity.reserve reserves it.
* Mark messages from verified senders, and verified sessions in :users,
with a check mark. Nicknames may not contain check marks.
* Move messages.log to format v3, which records verified senders. v1 and
v2 logs are migrated in place at start.

-- M1ng <contact@m1ng.space> Wed, 14 Oct 2026 00:00:00 +0800

tnt-chat (1.6.0-1) unstable; urgency=medium

* Add the message:private module permission. Users the module reports
Expand Down
2 changes: 1 addition & 1 deletion packaging/homebrew/tnt-chat.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
class TntChat < Formula
desc "SSH-native terminal chat server with a Vim-style interface"
homepage "https://github.com/m1ngsama/TNT"
url "https://github.com/m1ngsama/TNT/releases/download/v1.6.0/tnt-chat-v1.6.0-source.tar.gz"
url "https://github.com/m1ngsama/TNT/releases/download/v1.7.0/tnt-chat-v1.7.0-source.tar.gz"
sha256 "REPLACE_WITH_RELEASE_TARBALL_SHA256"
license "MIT"

Expand Down
43 changes: 33 additions & 10 deletions src/commands.c
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include "client.h"
#include "command_catalog.h"
#include "common.h"
#include "history_view.h"
#include "i18n.h"
#include "identity.h"
#include "keymap.h"
Expand Down Expand Up @@ -53,20 +54,34 @@ static void append_highlighted(char *output, size_t buf_size, size_t *pos,
}

static bool append_message_row(char *output, size_t buf_size, size_t *pos,
const message_t *msg, const char *needle) {
const message_t *msg, const char *needle,
const theme_t *theme) {
size_t mark = *pos;
size_t actor_len = history_view_action_actor_len(msg);
char actor[MAX_USERNAME_LEN];
char visible[MAX_MESSAGE_LEN];
richtext_run_t runs[RICHTEXT_MAX_RUNS];
size_t run_count = richtext_parse(msg->content, visible, sizeof(visible),
NULL, runs, RICHTEXT_MAX_RUNS);
size_t run_count = richtext_parse(history_view_message_body(msg), visible,
sizeof(visible), NULL, runs,
RICHTEXT_MAX_RUNS);
char ts[20];
struct tm tmi;

localtime_r(&msg->timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, buf_size, pos, "[%s] ", ts);
append_highlighted(output, buf_size, pos, msg->username, NULL, 0, needle);
buffer_appendf(output, buf_size, pos, ": ");
if (actor_len > 0) {
snprintf(actor, sizeof(actor), "%.*s", (int)actor_len, msg->content);
buffer_appendf(output, buf_size, pos, ": ");
append_highlighted(output, buf_size, pos, actor, NULL, 0, needle);
}
if (msg->verified) {
buffer_appendf(output, buf_size, pos,
" %s" HISTORY_VIEW_VERIFIED_MARK "\033[0m",
theme->verified);
}
buffer_appendf(output, buf_size, pos, actor_len > 0 ? " " : ": ");
append_highlighted(output, buf_size, pos, visible, runs, run_count,
needle);
buffer_appendf(output, buf_size, pos, "\n");
Expand Down Expand Up @@ -304,7 +319,7 @@ int commands_rename(client_t *client, const char *new_name, bool verified) {
client->ui_lang);
room_broadcast(g_room, &nick_msg);
message_save(&nick_msg);
tnt_module_runtime_publish_message_created(&nick_msg, false);
tnt_module_runtime_publish_message_created(&nick_msg);
return 0;
}

Expand Down Expand Up @@ -436,9 +451,15 @@ void commands_dispatch(client_t *client) {
}
/* 1-column gutter: ▎ for you, blank for others */
buffer_appendf(output, sizeof(output), &pos,
"%s \033[37m%s\033[0m \033[2;37m· %s\033[0m\n",
is_self ? self_gutter : " ",
g_room->clients[i]->username, dur_str);
"%s \033[37m%s\033[0m", is_self ? self_gutter : " ",
g_room->clients[i]->username);
if (g_room->clients[i]->verified) {
buffer_appendf(output, sizeof(output), &pos,
" %s" HISTORY_VIEW_VERIFIED_MARK "\033[0m",
theme->verified);
}
buffer_appendf(output, sizeof(output), &pos,
" \033[2;37m· %s\033[0m\n", dur_str);
}
pthread_rwlock_unlock(&g_room->lock);
for (int i = 0; i < remote_count; i++) {
Expand Down Expand Up @@ -649,7 +670,8 @@ void commands_dispatch(client_t *client) {
}
for (int i = 0; i < last_count; i++) {
if (!append_message_row(output, sizeof(output), &pos,
&last_msgs[start + i], NULL)) {
&last_msgs[start + i], NULL,
theme_resolve(client->theme_index))) {
break;
}
}
Expand Down Expand Up @@ -684,7 +706,8 @@ void commands_dispatch(client_t *client) {
}
for (int i = 0; i < display_count; i++) {
if (!append_message_row(output, sizeof(output), &pos,
&found[start + i], query)) {
&found[start + i], query,
theme_resolve(client->theme_index))) {
break;
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/common.c
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ bool is_valid_username(const char *username) {
return false;
}

/* The chat view marks verified senders with a check mark. */
static const char *const check_marks[] = {"✓", "✔", "☑", "✅"};
for (size_t i = 0; i < sizeof(check_marks) / sizeof(check_marks[0]); i++) {
if (strstr(username, check_marks[i])) {
return false;
}
}

/* Check for illegal characters that could cause injection */
const char *illegal_chars = "|;&$`\n\r<>(){}[]'\"\\";
length = strlen(username);
Expand Down
5 changes: 2 additions & 3 deletions src/exec.c
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,6 @@ static int exec_command_post(client_t *client, const char *args) {
char content[MAX_MESSAGE_LEN];
char username[MAX_USERNAME_LEN];
char handle[TNT_IDENTITY_HANDLE_MAX + 1];
bool verified = false;
message_t msg = {
.timestamp = time(NULL),
};
Expand Down Expand Up @@ -423,7 +422,7 @@ static int exec_command_post(client_t *client, const char *args) {
return TNT_EXIT_ERROR;
}
snprintf(username, sizeof(username), "%s", handle);
verified = true;
msg.verified = true;
}

if (strncmp(content, "/me ", 4) == 0 && content[4] != '\0') {
Expand Down Expand Up @@ -459,7 +458,7 @@ static int exec_command_post(client_t *client, const char *args) {

room_broadcast(g_room, &msg);
notify_mentions(msg.content, client);
tnt_module_runtime_publish_message_created(&msg, verified);
tnt_module_runtime_publish_message_created(&msg);

if (client_send(client, "posted\n", 7) != 0) {
return TNT_EXIT_ERROR;
Expand Down
Loading
Loading