diff --git a/docs/database_setup.md b/docs/database_setup.md new file mode 100644 index 0000000..719b287 --- /dev/null +++ b/docs/database_setup.md @@ -0,0 +1,157 @@ +# Database Setup Guide + +This guide explains how to set up the Tumble database for both MySQL and SQLite. + +## Quick Start + +### SQLite Setup (Recommended for Development) + +1. Create or edit `config.yaml`: + ```yaml + driver: sqlite + database_file: tumble.db + baseurl: your.domain.com + ``` + +2. Run the setup script: + ```bash + perl scripts/setup_database.pl + ``` + +3. Done! Your SQLite database is ready at `tumble.db` + +### MySQL Setup (Production) + +1. Create or edit `config.yaml`: + ```yaml + driver: mysql + database: tumble + username: tumble + password: your_secure_password + host: localhost + baseurl: your.domain.com + ``` + +2. (Optional) Create the MySQL user and database manually: + ```bash + mysql -u root -p < sql/sql_setup + ``` + + Or let the setup script create the database automatically. + +3. Run the setup script: + ```bash + perl scripts/setup_database.pl + ``` + +4. Done! Your MySQL database is ready. + +## Configuration Options + +### SQLite Configuration + +| Option | Required | Default | Description | +|--------|----------|---------|-------------| +| `driver` | Yes | `mysql` | Must be set to `sqlite` | +| `database_file` | No | `tumble.db` | Path to SQLite database file | +| `baseurl` | Yes | - | Base URL for the application | + +### MySQL Configuration + +| Option | Required | Default | Description | +|--------|----------|---------|-------------| +| `driver` | No | `mysql` | Can be omitted or set to `mysql` | +| `database` | Yes | - | MySQL database name | +| `username` | Yes | - | MySQL username | +| `password` | No | - | MySQL password | +| `host` | No | `localhost` | MySQL server hostname | +| `baseurl` | Yes | - | Base URL for the application | + +## Schema Files + +The setup script uses database-specific schema files: + +- **[sql/schema.mysql](file:///Users/stahnma/development/personal/tumble/sql/schema.mysql)** - MySQL schema with MyISAM engine and FULLTEXT indexes +- **[sql/schema.sqlite](file:///Users/stahnma/development/personal/tumble/sql/schema.sqlite)** - SQLite schema with compatible data types + +Both schemas create the same tables: +- `image` - Image posts +- `ircLink` - Link posts with click tracking +- `quote` - Quote posts +- `schema_version` - Migration tracking (for future use) + +## Differences Between MySQL and SQLite + +### Full-Text Search + +- **MySQL**: Uses native `FULLTEXT` indexes on `ircLink.title` and `ircLink.url` +- **SQLite**: Uses `LIKE`-based search (slower but functional) + +For better SQLite performance, consider implementing FTS5 virtual tables in the future. + +### Date Functions + +The `tumble::DB` abstraction layer handles date differences: +- **MySQL**: `DATE_SUB(CURDATE(), INTERVAL N DAY)` +- **SQLite**: `date('now', '-N days')` + +### Data Types + +- **MySQL**: Uses specific types like `int(16)`, `varchar(255)`, `text` +- **SQLite**: Uses `INTEGER`, `TEXT`, `DATETIME` + +## Troubleshooting + +### "Could not connect to MySQL server" + +If you see this error, the script will attempt to connect directly to the database. Make sure: +1. MySQL server is running +2. The database exists (or the user has CREATE DATABASE privileges) +3. Username and password are correct + +### SQLite file permissions + +Make sure the directory containing the SQLite database file is writable by the web server user. + +### Verbose Output + +For debugging, run with verbose output: +```bash +VERBOSE=1 perl scripts/setup_database.pl +``` + +## Migration from MySQL to SQLite + +To migrate from MySQL to SQLite: + +1. Export data from MySQL: + ```bash + mysqldump -u tumble -p tumble > tumble_backup.sql + ``` + +2. Convert the dump to SQLite format (manual process or use a conversion tool) + +3. Update `config.yaml` to use SQLite + +4. Run the setup script to create the SQLite schema + +5. Import the converted data + +## Advanced: Manual Setup + +If you prefer to run SQL manually: + +### MySQL +```bash +mysql -u tumble -p tumble < sql/schema.mysql +``` + +### SQLite +```bash +sqlite3 tumble.db < sql/schema.sqlite +``` + +## See Also + +- [Database Abstraction Implementation](file:///Users/stahnma/.gemini/antigravity/brain/6a9a5350-de23-4a28-bcc5-704005d13bc4/walkthrough.md) - Technical details of the DB abstraction layer +- [scripts/setup_database.pl](file:///Users/stahnma/development/personal/tumble/scripts/setup_database.pl) - Setup script source code diff --git a/htdocs/irclink/index.cgi b/htdocs/irclink/index.cgi index a285ea7..f4b1b2d 100755 --- a/htdocs/irclink/index.cgi +++ b/htdocs/irclink/index.cgi @@ -2,7 +2,7 @@ BEGIN { unshift @INC, '../lib'; } -use lsrfsh::MySQL; +use tumble::DB; use CGI; use DBI; @@ -11,7 +11,7 @@ use LWP::UserAgent; use strict; my $cgi = new CGI; -my $dbh = lsrfsh::MySQL->new( config => '../config.yaml' ); +my $dbh = tumble::DB->new( config => '../config.yaml' ); if ( $cgi->param( 'user' ) && $cgi->param( 'url' ) ) { my $user = $cgi->param( 'user' ); diff --git a/htdocs/lib/tumble.pm b/htdocs/lib/tumble.pm index 2289fcc..29e0adc 100755 --- a/htdocs/lib/tumble.pm +++ b/htdocs/lib/tumble.pm @@ -2,7 +2,7 @@ package tumble; use base 'CGI::Application'; -use lsrfsh::MySQL; +use tumble::DB; use DBI; use strict; @@ -40,7 +40,7 @@ sub setup { /html/ && do { $self->header_props( -type => 'text/html; charset=UTF-8' ); }; } - $self->{'dbh'} = lsrfsh::MySQL->new( config => 'config.yaml' ); + $self->{'dbh'} = tumble::DB->new( config => 'config.yaml' ); $self->{'content_processor'} = tumble::Content->new( config => $CONFIG ); $self->start_mode( 'displayTumble' ); @@ -54,12 +54,14 @@ sub displayTumble { my ( $filter, $data, $r ); if ( $self->{'arg'}->{'i'} ) { - $filter = "DATE_SUB(CURDATE(), INTERVAL " . $self->{'arg'}->{'i'} * 6 - . " DAY) <= timestamp AND DATE_SUB(CURDATE(), INTERVAL " - . ( $self->{'arg'}->{'i'} - 1 ) * 6 . " DAY) >= timestamp"; + my $start_days = $self->{'arg'}->{'i'} * 6; + my $end_days = ( $self->{'arg'}->{'i'} - 1 ) * 6; + + $filter = $self->{'dbh'}->date_interval_sql($start_days) . " <= timestamp AND " . + $self->{'dbh'}->date_interval_sql($end_days) . " >= timestamp"; } else { - $filter = "DATE_SUB(CURDATE(), INTERVAL 6 DAY) <= timestamp"; + $filter = $self->{'dbh'}->date_interval_sql(6) . " <= timestamp"; } foreach my $type ( qw( ircLink image quote ) ) { @@ -170,15 +172,10 @@ sub displayTumble { $nav->{'n'} = '' unless $self->{'arg'}->{'i'}; if ( $self->{'arg'}->{'dtype'} =~ /html/ ) { - $filter = qq{ - DATE_SUB( - CURDATE(), INTERVAL 12 DAY - ) <= timestamp - AND DATE_SUB( - CURDATE(), INTERVAL 6 DAY - ) >= timestamp - AND clicks > 1 - }; + $filter = $self->{'dbh'}->date_interval_sql(12) . " <= timestamp" . + " AND " . + $self->{'dbh'}->date_interval_sql(6) . " >= timestamp" . + " AND clicks > 1"; my $hot = $self->{'dbh'}->fetch( source => 'ircLink', diff --git a/htdocs/lib/tumble/DB.pm b/htdocs/lib/tumble/DB.pm new file mode 100644 index 0000000..4632562 --- /dev/null +++ b/htdocs/lib/tumble/DB.pm @@ -0,0 +1,29 @@ +package tumble::DB; + +use strict; +use warnings; +use YAML qw( LoadFile ); +use tumble::DB::MySQL; +use tumble::DB::SQLite; + +sub new { + my $class = shift; + my %args = @_; + + my $config_file = $args{config} || 'config.yaml'; + my $config = LoadFile($config_file); + + my $driver = $config->{driver} || 'mysql'; # Default to MySQL + + if ( lc($driver) eq 'sqlite' ) { + return tumble::DB::SQLite->new( %args, config_data => $config ); + } + elsif ( lc($driver) eq 'mysql' ) { + return tumble::DB::MySQL->new( %args, config_data => $config ); + } + else { + die "Unknown database driver: $driver"; + } +} + +1; diff --git a/htdocs/lib/tumble/DB/Base.pm b/htdocs/lib/tumble/DB/Base.pm new file mode 100644 index 0000000..f466aeb --- /dev/null +++ b/htdocs/lib/tumble/DB/Base.pm @@ -0,0 +1,92 @@ +package tumble::DB::Base; + +use strict; +use warnings; +use DBI; + +sub new { + my $class = shift; + my $self = bless {}, $class; + + my %args = @_; + $self->{config} = $args{config_data}; + + $self->_connect(); + + return $self; +} + +sub _connect { + die "Subclass must implement _connect"; +} + +sub fetch { + my $self = shift; + my %args = @_; + + $args{key} ||= $args{source} . 'ID'; + + my $where_clause = ''; + if ( $args{filter} ) { + $where_clause = "WHERE $args{filter}"; + } + + my $order_clause = "ORDER BY $args{key}"; + if ( $args{order} ) { + $order_clause .= " $args{order}"; + } + + my $limit_clause = ''; + if ( $args{limit} ) { + $limit_clause = "LIMIT $args{limit}"; + } + + my $sql = "SELECT * FROM $args{source} $where_clause $order_clause $limit_clause"; + + return $self->{dbi}->selectall_hashref( + $sql, + $args{key} + ); +} + +sub post { + my $self = shift; + my %args = @_; + + $args{authorName} ||= 'anonymous'; + + my $table = delete $args{destination}; + my @columns = sort keys %args; + + my $cols_str = join( ', ', map { $self->quote_identifier($_) } @columns ); + my $vals_str = join( ', ', map { $self->{dbi}->quote($args{$_}) } @columns ); + + my $sql = "INSERT INTO " . $self->quote_identifier($table) . " ( $cols_str ) VALUES ( $vals_str )"; + + $self->{dbi}->do($sql); +} + +sub disconnect { + my $self = shift; + $self->{dbi}->disconnect() if $self->{dbi}; +} + +# Helper to quote identifiers (table/column names) - overridden by drivers if needed +sub quote_identifier { + my ($self, $ident) = @_; + return "`$ident`"; # Default to backticks (MySQL style) +} + +sub date_interval_sql { + die "Subclass must implement date_interval_sql"; +} + +sub fulltext_search_sql { + die "Subclass must implement fulltext_search_sql"; +} + +# Pass-through DBI +sub prepare { return shift->{dbi}->prepare(@_); } +sub selectrow_array { return shift->{dbi}->selectrow_array(@_); } + +1; diff --git a/htdocs/lib/tumble/DB/MySQL.pm b/htdocs/lib/tumble/DB/MySQL.pm new file mode 100644 index 0000000..8cc3d15 --- /dev/null +++ b/htdocs/lib/tumble/DB/MySQL.pm @@ -0,0 +1,43 @@ +package tumble::DB::MySQL; + +use strict; +use warnings; +use base 'tumble::DB::Base'; + +sub _connect { + my $self = shift; + my $config = $self->{config}; + + my $dsn = "dbi:mysql:$config->{database}"; + $dsn .= ";host=$config->{host}" if $config->{host}; + + $self->{dbi} = DBI->connect( + $dsn, + $config->{username}, + $config->{password}, + { RaiseError => 1, AutoCommit => 1 } + ) or die "Can't connect: $DBI::errstr"; +} + +sub date_interval_sql { + my ($self, $days, $op) = @_; + # Defaults: $days is number of days, $op is '<=' or '>=' + # Current code uses: DATE_SUB(CURDATE(), INTERVAL $days DAY) <= timestamp + + # We'll return the expression 'timestamp' should be compared against, + # or the full condition string? + # The existing code constructs: "DATE_SUB(CURDATE(), INTERVAL $i * 6 DAY) <= timestamp" + # To make it cleaner, let's allow generating the LHS. + + return "DATE_SUB(CURDATE(), INTERVAL $days DAY)"; +} + +sub fulltext_search_sql { + my ($self, $cols, $query) = @_; + # MATCH (cols) AGAINST ('query') + # We need to trust the caller to sanitize or we quote specific parts? + # Simpler: just return string structure + return "MATCH ($cols) AGAINST (" . $self->{dbi}->quote($query) . ")"; +} + +1; diff --git a/htdocs/lib/tumble/DB/SQLite.pm b/htdocs/lib/tumble/DB/SQLite.pm new file mode 100644 index 0000000..d8f116c --- /dev/null +++ b/htdocs/lib/tumble/DB/SQLite.pm @@ -0,0 +1,50 @@ +package tumble::DB::SQLite; + +use strict; +use warnings; +use base 'tumble::DB::Base'; + +sub _connect { + my $self = shift; + my $config = $self->{config}; + + my $dbfile = $config->{database_file} || 'tumble.db'; + + $self->{dbi} = DBI->connect( + "dbi:SQLite:dbname=$dbfile", + "", + "", + { RaiseError => 1, AutoCommit => 1 } + ) or die "Can't connect: $DBI::errstr"; +} + +sub quote_identifier { + my ($self, $ident) = @_; + return qq("$ident"); # Double quotes for standard SQL / SQLite +} + +sub date_interval_sql { + my ($self, $days) = @_; + # SQLite: date('now', '-$days days') + return "date('now', '-$days days')"; +} + +sub fulltext_search_sql { + my ($self, $cols, $query) = @_; + # SQLite basic substitute: OR of LIKEs? + # Or assuming one col for now, or concat? + # Simple fallback: LIKE + # Since existing searches 'title,url', we might match either. + + my @fields = split( /,/, $cols ); + my @parts = (); + my $q = $self->{dbi}->quote("%$query%"); + + foreach my $f (@fields) { + push @parts, "$f LIKE $q"; + } + + return "(" . join( " OR ", @parts ) . ")"; +} + +1; diff --git a/htdocs/lib/tumble/search.pm b/htdocs/lib/tumble/search.pm index dbd852e..23cb3a0 100755 --- a/htdocs/lib/tumble/search.pm +++ b/htdocs/lib/tumble/search.pm @@ -2,7 +2,7 @@ package tumble::search; use base 'CGI::Application'; -use lsrfsh::MySQL; +use tumble::DB; use YAML qw( LoadFile ); use Cwd qw( abs_path getcwd ); use File::Spec; @@ -36,7 +36,7 @@ sub setup { /rss|xml/ && do { $self->header_props( -type => 'text/xml' ); }; } - $self->{'dbh'} = lsrfsh::MySQL->new( config => 'config.yaml' ); + $self->{'dbh'} = tumble::DB->new( config => 'config.yaml' ); $self->start_mode( 'displaySearch' ); @@ -49,9 +49,11 @@ sub displaySearch { my $string = 'unicorn'; return unless $string; + my $search_filter = $self->{'dbh'}->fulltext_search_sql('title,url', $self->{'arg'}->{'search'}); + my $raw = $self->{'dbh'}->fetch( source => 'ircLink', - filter => "MATCH (title,url) AGAINST ('$self->{'arg'}->{'search'}')", + filter => $search_filter, key => 'ircLinkID' ); @@ -93,15 +95,10 @@ sub displaySearch { ); } - my $filter = qq{ - DATE_SUB( - CURDATE(), INTERVAL 12 DAY - ) <= timestamp - AND DATE_SUB( - CURDATE(), INTERVAL 6 DAY - ) >= timestamp - AND clicks > 1 - }; + my $filter = $self->{'dbh'}->date_interval_sql(12) . " <= timestamp" . + " AND " . + $self->{'dbh'}->date_interval_sql(6) . " >= timestamp" . + " AND clicks > 1"; my $hot = $self->{'dbh'}->fetch( source => 'ircLink', diff --git a/htdocs/quote/index.cgi b/htdocs/quote/index.cgi index 020a7ee..9bd1a94 100755 --- a/htdocs/quote/index.cgi +++ b/htdocs/quote/index.cgi @@ -2,7 +2,7 @@ BEGIN { unshift @INC, '../lib'; } -use lsrfsh::MySQL; +use tumble::DB; use CGI; use DBI; @@ -11,7 +11,7 @@ use URI::Escape; use strict; my $cgi = new CGI; -my $dbh = lsrfsh::MySQL->new( config => '../config.yaml' ); +my $dbh = tumble::DB->new( config => '../config.yaml' ); if ( $cgi->param( 'quote' ) && $cgi->param( 'author' ) ) { my $quote = $cgi->param( 'quote' ); diff --git a/scripts/setup_database.pl b/scripts/setup_database.pl new file mode 100755 index 0000000..13a1b21 --- /dev/null +++ b/scripts/setup_database.pl @@ -0,0 +1,248 @@ +#!/usr/bin/env perl + +=head1 NAME + +setup_database.pl - Database-agnostic setup script for Tumble + +=head1 SYNOPSIS + + perl scripts/setup_database.pl [--config=path/to/config.yaml] + +=head1 DESCRIPTION + +This script initializes the Tumble database based on the driver specified +in config.yaml. It supports both MySQL and SQLite databases. + +=cut + +use strict; +use warnings; +use FindBin; +use lib "$FindBin::Bin/../htdocs/lib"; +use YAML qw( LoadFile ); +use DBI; +use Getopt::Long; + +my $config_file = 'config.yaml'; +GetOptions( + 'config=s' => \$config_file, +) or die "Usage: $0 [--config=path/to/config.yaml]\n"; + +# Load configuration +my $config = LoadFile($config_file); +my $driver = lc($config->{driver} || 'mysql'); + +print "=" x 60 . "\n"; +print "Tumble Database Setup\n"; +print "=" x 60 . "\n"; +print "Driver: $driver\n"; +print "Config: $config_file\n"; +print "=" x 60 . "\n\n"; + +if ($driver eq 'mysql') { + setup_mysql($config); +} elsif ($driver eq 'sqlite') { + setup_sqlite($config); +} else { + die "Unknown database driver: $driver\n"; +} + +print "\n" . "=" x 60 . "\n"; +print "Database setup completed successfully!\n"; +print "=" x 60 . "\n"; + +exit 0; + +# +# MySQL Setup +# +sub setup_mysql { + my ($config) = @_; + + print "Setting up MySQL database...\n\n"; + + # Check required config + die "Missing 'database' in config\n" unless $config->{database}; + die "Missing 'username' in config\n" unless $config->{username}; + + my $database = $config->{database}; + my $username = $config->{username}; + my $password = $config->{password} || ''; + my $host = $config->{host} || 'localhost'; + + # Connect to MySQL server (without database) + print "Connecting to MySQL server at $host...\n"; + my $dsn = "dbi:mysql:host=$host"; + my $dbh = DBI->connect($dsn, $username, $password, { + RaiseError => 0, + PrintError => 0, + }); + + if (!$dbh) { + print "Warning: Could not connect to MySQL server.\n"; + print "Error: $DBI::errstr\n"; + print "Attempting to connect directly to database '$database'...\n\n"; + + # Try connecting directly to the database (assume it exists) + $dsn = "dbi:mysql:database=$database;host=$host"; + $dbh = DBI->connect($dsn, $username, $password, { + RaiseError => 1, + PrintError => 1, + }) or die "Could not connect to database: $DBI::errstr\n"; + } else { + # Create database if it doesn't exist + print "Creating database '$database' if it doesn't exist...\n"; + $dbh->do("CREATE DATABASE IF NOT EXISTS `$database`") + or die "Could not create database: " . $dbh->errstr . "\n"; + + # Switch to the database + $dbh->do("USE `$database`") + or die "Could not use database: " . $dbh->errstr . "\n"; + } + + print "Connected to database '$database'\n\n"; + + # Read and execute schema file + my $schema_file = "$FindBin::Bin/../sql/schema.mysql"; + print "Executing schema from: $schema_file\n"; + + open my $fh, '<', $schema_file or die "Could not open $schema_file: $!\n"; + + # Read and execute SQL statements + my $current_stmt = ''; + my $count = 0; + + while (my $line = <$fh>) { + # Skip comment-only lines + next if $line =~ /^\s*--/; + + # Remove inline comments + $line =~ s/--.*$//; + + # Accumulate the statement + $current_stmt .= $line; + + # If we hit a semicolon, execute the statement + if ($line =~ /;\s*$/) { + $current_stmt =~ s/^\s+|\s+$//g; # Trim + + if ($current_stmt && $current_stmt !~ /^\s*$/) { + eval { + $dbh->do($current_stmt); + $count++; + print " ✓ Executed statement $count\n" if $ENV{VERBOSE}; + }; + if ($@) { + warn "Warning executing statement: $@\n"; + warn "Statement was: $current_stmt\n" if $ENV{VERBOSE}; + } + } + + $current_stmt = ''; + } + } + + close $fh; + + print "Executed $count SQL statements\n"; + + $dbh->disconnect(); + print "\nMySQL setup complete!\n"; +} + +# +# SQLite Setup +# +sub setup_sqlite { + my ($config) = @_; + + print "Setting up SQLite database...\n\n"; + + my $dbfile = $config->{database_file} || 'tumble.db'; + + print "Database file: $dbfile\n"; + + if (-e $dbfile) { + print "Warning: Database file already exists. Tables will be created if they don't exist.\n\n"; + } + + # Connect to SQLite + print "Connecting to SQLite database...\n"; + my $dbh = DBI->connect("dbi:SQLite:dbname=$dbfile", "", "", { + RaiseError => 1, + PrintError => 1, + }) or die "Could not connect to SQLite: $DBI::errstr\n"; + + print "Connected successfully\n\n"; + + # Read and execute schema file + my $schema_file = "$FindBin::Bin/../sql/schema.sqlite"; + print "Executing schema from: $schema_file\n"; + + open my $fh, '<', $schema_file or die "Could not open $schema_file: $!\n"; + + # Read and execute SQL statements + my $current_stmt = ''; + my $count = 0; + + while (my $line = <$fh>) { + # Skip comment-only lines + next if $line =~ /^\s*--/; + + # Remove inline comments + $line =~ s/--.*$//; + + # Accumulate the statement + $current_stmt .= $line; + + # If we hit a semicolon, execute the statement + if ($line =~ /;\s*$/) { + $current_stmt =~ s/^\s+|\s+$//g; # Trim + + if ($current_stmt && $current_stmt !~ /^\s*$/) { + eval { + $dbh->do($current_stmt); + $count++; + print " ✓ Executed statement $count\n" if $ENV{VERBOSE}; + }; + if ($@) { + warn "Warning executing statement: $@\n"; + warn "Statement was: $current_stmt\n" if $ENV{VERBOSE}; + } + } + + $current_stmt = ''; + } + } + + close $fh; + + print "Executed $count SQL statements\n"; + + $dbh->disconnect(); + print "\nSQLite setup complete!\n"; + print "Database location: $dbfile\n"; +} + +__END__ + +=head1 CONFIGURATION + +The script reads config.yaml to determine which database driver to use. + +For MySQL: + driver: mysql + database: tumble + username: tumble + password: your_password + host: localhost + +For SQLite: + driver: sqlite + database_file: tumble.db + +=head1 AUTHOR + +Tumble Development Team + +=cut diff --git a/sql/migrations b/sql/schema.mysql similarity index 66% rename from sql/migrations rename to sql/schema.mysql index 51ae97d..0527674 100644 --- a/sql/migrations +++ b/sql/schema.mysql @@ -1,4 +1,7 @@ --- Migration 001 +-- MySQL Schema for Tumble +-- Original migrations file, now renamed for clarity + +-- Migration 001: Initial schema CREATE TABLE IF NOT EXISTS `image` ( `imageID` int(16) NOT NULL AUTO_INCREMENT, `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -18,7 +21,8 @@ CREATE TABLE IF NOT EXISTS `ircLink` ( `user` varchar(9) NOT NULL DEFAULT '', `title` varchar(255) NOT NULL DEFAULT '', `url` text NOT NULL, - `clicks` int(16) NOT NULL, + `clicks` int(16) NOT NULL DEFAULT 0, + `content_type` varchar(40), PRIMARY KEY (`ircLinkID`), KEY `ircLinkID` (`ircLinkID`), KEY `ircindex` (`ircLinkID`), @@ -36,5 +40,14 @@ CREATE TABLE IF NOT EXISTS `quote` ( KEY `quoteindex` (`quoteID`) ) ENGINE=MyISAM AUTO_INCREMENT=4778 DEFAULT CHARSET=latin1; --- Migration 002 -alter table ircLink add column content_type varchar(40); +-- Schema version tracking table +CREATE TABLE IF NOT EXISTS `schema_version` ( + `version` int(11) NOT NULL, + `applied_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `description` varchar(255), + PRIMARY KEY (`version`) +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- Record schema versions +INSERT IGNORE INTO `schema_version` (`version`, `description`) VALUES (1, 'Initial schema'); +INSERT IGNORE INTO `schema_version` (`version`, `description`) VALUES (2, 'Added content_type to ircLink'); diff --git a/sql/schema.sqlite b/sql/schema.sqlite new file mode 100644 index 0000000..266230e --- /dev/null +++ b/sql/schema.sqlite @@ -0,0 +1,46 @@ +-- SQLite Schema for Tumble +-- Translated from MySQL schema in migrations file + +-- Migration 001: Initial schema +CREATE TABLE IF NOT EXISTS image ( + imageID INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + title TEXT NOT NULL DEFAULT '', + link TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL, + md5sum TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_image_id ON image(imageID); + +CREATE TABLE IF NOT EXISTS ircLink ( + ircLinkID INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + user TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL, + clicks INTEGER NOT NULL DEFAULT 0, + content_type TEXT +); + +CREATE INDEX IF NOT EXISTS idx_irclink_id ON ircLink(ircLinkID); + +-- Note: SQLite doesn't have native FULLTEXT like MySQL's MyISAM +-- The tumble::DB::SQLite driver uses LIKE-based search instead +-- For better performance, consider using FTS5 virtual tables in the future + +CREATE TABLE IF NOT EXISTS quote ( + quoteID INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + quote TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_quote_id ON quote(quoteID); + +-- Schema version tracking table +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + description TEXT +); diff --git a/t/db_abstraction.t b/t/db_abstraction.t new file mode 100644 index 0000000..38cd020 --- /dev/null +++ b/t/db_abstraction.t @@ -0,0 +1,57 @@ +#!/usr/bin/env perl + +use strict; +use warnings; +use Test::More tests => 8; +use FindBin; +use lib "$FindBin::Bin/../htdocs/lib"; + +# Test 1: Load the DB module +BEGIN { use_ok('tumble::DB') } + +# Test 2: Load MySQL driver +BEGIN { use_ok('tumble::DB::MySQL') } + +# Test 3: Load SQLite driver +BEGIN { use_ok('tumble::DB::SQLite') } + +# Test 4: Load Base class +BEGIN { use_ok('tumble::DB::Base') } + +# Test 5: MySQL date_interval_sql +{ + my $mysql = bless { dbi => undef }, 'tumble::DB::MySQL'; + my $sql = $mysql->date_interval_sql(6); + is($sql, "DATE_SUB(CURDATE(), INTERVAL 6 DAY)", "MySQL date_interval_sql generates correct SQL"); +} + +# Test 6: SQLite date_interval_sql +{ + my $sqlite = bless { dbi => undef }, 'tumble::DB::SQLite'; + my $sql = $sqlite->date_interval_sql(6); + is($sql, "date('now', '-6 days')", "SQLite date_interval_sql generates correct SQL"); +} + +# Test 7: MySQL fulltext_search_sql (mock DBI quote) +{ + package MockDBI; + sub quote { my ($self, $str) = @_; return "'$str'"; } + + package main; + my $mysql = bless { dbi => bless({}, 'MockDBI') }, 'tumble::DB::MySQL'; + my $sql = $mysql->fulltext_search_sql('title,url', 'test query'); + is($sql, "MATCH (title,url) AGAINST ('test query')", "MySQL fulltext_search_sql generates correct SQL"); +} + +# Test 8: SQLite fulltext_search_sql (mock DBI quote) +{ + package MockDBI2; + sub quote { my ($self, $str) = @_; return "'$str'"; } + + package main; + my $sqlite = bless { dbi => bless({}, 'MockDBI2') }, 'tumble::DB::SQLite'; + my $sql = $sqlite->fulltext_search_sql('title,url', 'test'); + like($sql, qr/title LIKE '%test%' OR url LIKE '%test%'/, "SQLite fulltext_search_sql generates LIKE-based SQL"); +} + +done_testing(); -- 2.51.2 From b8861cb2937627ae06803e1f24dffb78ea213d7b Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Thu, 8 Jan 2026 23:27:38 -0600 Subject: [PATCH 002/231] Update README with database setup instructions - Added Database Support section explaining MySQL vs SQLite options - Replaced old MySQL-only setup with database-agnostic quick setup guide - Added SQLite configuration example (easiest option) - Updated MySQL setup to reference new schema.mysql file - Added migration instructions from MySQL to SQLite - Linked to detailed docs/database_setup.md for comprehensive guide - Simplified setup process to single command: perl scripts/setup_database.pl --- README.md | 97 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b82cb6b..dcca1e8 100644 --- a/README.md +++ b/README.md @@ -13,27 +13,92 @@ The easiet way to deploy is type is to clone and type `make rpm` on an EL6 syste If you are not on EL, things should still work. Just `make install` or package it yourself. -## Tumble setup +## Database Support -1. Get a flickr account. -1. Set flickr account in scripts/flickr (since this is not abstracted yet) -1. Change the passwords/usernames in sql_setup -1. Change the passwords/usernames in the config.yaml -1. Change url, server configuration etc in /etc/httpd/conf.d/tumble.conf -1. Disable selinux or set proper context -1. Start up httpd -1. Setup database +Tumble now supports both **MySQL** and **SQLite** databases. Choose the one that fits your needs: +- **SQLite**: Recommended for development, testing, and small deployments. No separate database server required. +- **MySQL**: Recommended for production deployments with higher traffic. + +## Quick Setup + +### 1. Configure Database + +Create or edit `config.yaml` in the htdocs directory: + +**For SQLite (easiest):** +```yaml +driver: sqlite +database_file: tumble.db +baseurl: your.domain.com +``` + +**For MySQL:** +```yaml +driver: mysql +database: tumble +username: tumble +password: your_secure_password +host: localhost +baseurl: your.domain.com +``` + +### 2. Initialize Database + +Run the setup script (works for both MySQL and SQLite): + +```bash +perl scripts/setup_database.pl +``` + +That's it! The script will automatically detect your database type and create the necessary tables. + +### 3. Configure Web Server + +1. Update `/etc/httpd/conf.d/tumble.conf` with your server configuration +2. Disable SELinux or set proper context +3. Start httpd: + +```bash +chkconfig httpd on +service httpd start ``` - yum install mysql-server - service mysqld start - chkconfig mysqld on - mysql < sql_setup - mysql -u tumble tumble < migrations - chkconfig httpd on - service httpd start + +## Detailed Setup (MySQL) + +If you prefer manual MySQL setup: + +```bash +# Install MySQL +yum install mysql-server +service mysqld start +chkconfig mysqld on + +# Create database and user +mysql -u root -p < sql/sql_setup + +# Run schema +mysql -u tumble -p tumble < sql/schema.mysql ``` +## Migration from MySQL to SQLite + +1. Export your MySQL data: + ```bash + mysqldump -u tumble -p tumble > tumble_backup.sql + ``` + +2. Update `config.yaml` to use SQLite + +3. Run setup script: + ```bash + perl scripts/setup_database.pl + ``` + +4. Import data (requires conversion from MySQL to SQLite format) + +See [docs/database_setup.md](docs/database_setup.md) for detailed instructions. + ## Bugs * fix user-agent being hardy for link verification -- 2.51.2 From df345e54b5754e82e9bf777c5d230da4a72f1a34 Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Thu, 8 Jan 2026 23:33:42 -0600 Subject: [PATCH 003/231] Add more diagonstics for DB connections --- htdocs/lib/tumble.pm | 30 +++++++- htdocs/lib/tumble/DB/Base.pm | 38 ++++++++- htdocs/lib/tumble/DB/MySQL.pm | 82 ++++++++++++++++++-- htdocs/lib/tumble/DB/SQLite.pm | 137 +++++++++++++++++++++++++++++++-- 4 files changed, 270 insertions(+), 17 deletions(-) diff --git a/htdocs/lib/tumble.pm b/htdocs/lib/tumble.pm index 29e0adc..a9ac679 100755 --- a/htdocs/lib/tumble.pm +++ b/htdocs/lib/tumble.pm @@ -40,7 +40,21 @@ sub setup { /html/ && do { $self->header_props( -type => 'text/html; charset=UTF-8' ); }; } - $self->{'dbh'} = tumble::DB->new( config => 'config.yaml' ); + # Initialize database connection with error handling + eval { + $self->{'dbh'} = tumble::DB->new( config => 'config.yaml' ); + }; + if ($@) { + warn "[tumble] FATAL: Failed to initialize database connection\n"; + warn "[tumble] Error: $@\n"; + warn "[tumble] Please check:\n"; + warn "[tumble] 1. config.yaml exists and is readable\n"; + warn "[tumble] 2. Database server is running (if using MySQL)\n"; + warn "[tumble] 3. Database file exists and is readable (if using SQLite)\n"; + warn "[tumble] 4. Database credentials are correct\n"; + die "Database initialization failed: $@"; + } + $self->{'content_processor'} = tumble::Content->new( config => $CONFIG ); $self->start_mode( 'displayTumble' ); @@ -78,6 +92,20 @@ sub displayTumble { $data->{$_}->{'type'} = $type; } keys %{$raw->{$type}} } + + # Check if we have any data at all + if (!$data || (ref($data) eq 'HASH' && scalar(keys %$data) == 0)) { + warn "[tumble] WARNING: No content found in database\n"; + warn "[tumble] Filter used: $filter\n"; + warn "[tumble] This could mean:\n"; + warn "[tumble] 1. Database is empty (no content has been added yet)\n"; + warn "[tumble] 2. No content matches the current date filter\n"; + warn "[tumble] 3. Database tables exist but contain no rows\n"; + warn "[tumble] Try:\n"; + warn "[tumble] - Adding some content to the database\n"; + warn "[tumble] - Checking if content exists: SELECT COUNT(*) FROM ircLink;\n"; + warn "[tumble] - Verifying the date filter is not too restrictive\n"; + } my ( $c, $d, $date ); diff --git a/htdocs/lib/tumble/DB/Base.pm b/htdocs/lib/tumble/DB/Base.pm index f466aeb..1bb416b 100644 --- a/htdocs/lib/tumble/DB/Base.pm +++ b/htdocs/lib/tumble/DB/Base.pm @@ -43,10 +43,40 @@ sub fetch { my $sql = "SELECT * FROM $args{source} $where_clause $order_clause $limit_clause"; - return $self->{dbi}->selectall_hashref( - $sql, - $args{key} - ); + # Log the SQL query if DEBUG environment variable is set + if ($ENV{TUMBLE_DEBUG}) { + warn "[DB] Executing query: $sql\n"; + } + + my $result; + eval { + $result = $self->{dbi}->selectall_hashref( + $sql, + $args{key} + ); + }; + + if ($@) { + warn "[DB] Query FAILED: $@\n"; + warn "[DB] SQL: $sql\n"; + die "Database query failed: $@"; + } + + # Check if result is empty and log + if ($result && ref($result) eq 'HASH') { + my $row_count = scalar keys %$result; + + if ($ENV{TUMBLE_DEBUG}) { + warn "[DB] Query returned $row_count row(s) from table '$args{source}'\n"; + } + + if ($row_count == 0) { + warn "[DB] No data found in table '$args{source}' with filter: " . + ($args{filter} || 'none') . "\n"; + } + } + + return $result; } sub post { diff --git a/htdocs/lib/tumble/DB/MySQL.pm b/htdocs/lib/tumble/DB/MySQL.pm index 8cc3d15..5c74642 100644 --- a/htdocs/lib/tumble/DB/MySQL.pm +++ b/htdocs/lib/tumble/DB/MySQL.pm @@ -11,12 +11,82 @@ sub _connect { my $dsn = "dbi:mysql:$config->{database}"; $dsn .= ";host=$config->{host}" if $config->{host}; - $self->{dbi} = DBI->connect( - $dsn, - $config->{username}, - $config->{password}, - { RaiseError => 1, AutoCommit => 1 } - ) or die "Can't connect: $DBI::errstr"; + # Log connection attempt (sanitize password for logging) + my $host_info = $config->{host} || 'localhost'; + my $db_name = $config->{database} || 'unknown'; + my $username = $config->{username} || 'unknown'; + + warn "[MySQL] Attempting to connect to database '$db_name' on host '$host_info' as user '$username'\n"; + + eval { + $self->{dbi} = DBI->connect( + $dsn, + $config->{username}, + $config->{password}, + { RaiseError => 1, AutoCommit => 1, PrintError => 0 } + ); + }; + + if ($@) { + my $error = $@; + warn "[MySQL] Connection FAILED: $error\n"; + warn "[MySQL] Diagnostics:\n"; + warn "[MySQL] - DSN: $dsn\n"; + warn "[MySQL] - Username: $username\n"; + warn "[MySQL] - Host: $host_info\n"; + warn "[MySQL] - Database: $db_name\n"; + warn "[MySQL] Troubleshooting suggestions:\n"; + warn "[MySQL] 1. Verify MySQL server is running: systemctl status mysql (or mysqld)\n"; + warn "[MySQL] 2. Check credentials in config.yaml are correct\n"; + warn "[MySQL] 3. Verify user has permissions: GRANT ALL ON $db_name.* TO '$username'\@'$host_info'\n"; + warn "[MySQL] 4. Check MySQL is listening on $host_info (check bind-address in my.cnf)\n"; + warn "[MySQL] 5. Verify database '$db_name' exists: SHOW DATABASES;\n"; + die "MySQL connection failed: $error"; + } + + if (!$self->{dbi}) { + warn "[MySQL] Connection FAILED: $DBI::errstr\n"; + warn "[MySQL] Diagnostics:\n"; + warn "[MySQL] - DSN: $dsn\n"; + warn "[MySQL] - Error: $DBI::errstr\n"; + die "Can't connect to MySQL: $DBI::errstr"; + } + + # Log successful connection with database info + warn "[MySQL] Connection SUCCESSFUL\n"; + + # Get and log basic database statistics + eval { + my $tables = $self->{dbi}->selectall_arrayref("SHOW TABLES"); + my $table_count = scalar @$tables; + warn "[MySQL] Database contains $table_count table(s)\n"; + + if ($table_count == 0) { + warn "[MySQL] WARNING: Database '$db_name' appears to be empty (no tables found)\n"; + warn "[MySQL] - You may need to run the database setup script\n"; + warn "[MySQL] - Check if schema files need to be imported\n"; + } else { + # Log table names for diagnostics + my @table_names = map { $_->[0] } @$tables; + warn "[MySQL] Tables: " . join(', ', @table_names) . "\n"; + + # Check for expected tables + my %tables_hash = map { $_ => 1 } @table_names; + my @expected = qw(ircLink image quote); + my @missing; + foreach my $expected_table (@expected) { + push @missing, $expected_table unless $tables_hash{$expected_table}; + } + + if (@missing) { + warn "[MySQL] WARNING: Missing expected tables: " . join(', ', @missing) . "\n"; + warn "[MySQL] - Database may not be fully initialized\n"; + } + } + }; + if ($@) { + warn "[MySQL] Could not retrieve database statistics: $@\n"; + } } sub date_interval_sql { diff --git a/htdocs/lib/tumble/DB/SQLite.pm b/htdocs/lib/tumble/DB/SQLite.pm index d8f116c..b6df563 100644 --- a/htdocs/lib/tumble/DB/SQLite.pm +++ b/htdocs/lib/tumble/DB/SQLite.pm @@ -10,12 +10,137 @@ sub _connect { my $dbfile = $config->{database_file} || 'tumble.db'; - $self->{dbi} = DBI->connect( - "dbi:SQLite:dbname=$dbfile", - "", - "", - { RaiseError => 1, AutoCommit => 1 } - ) or die "Can't connect: $DBI::errstr"; + # Log connection attempt with file path + use Cwd qw(abs_path getcwd); + my $abs_dbfile = abs_path($dbfile) || File::Spec->rel2abs($dbfile); + my $cwd = getcwd(); + + warn "[SQLite] Attempting to connect to database file: $dbfile\n"; + warn "[SQLite] Absolute path: $abs_dbfile\n"; + warn "[SQLite] Current working directory: $cwd\n"; + + # Check file existence and permissions before connecting + my $file_exists = -e $dbfile; + if ($file_exists) { + warn "[SQLite] Database file EXISTS\n"; + + # Check permissions + my $readable = -r $dbfile; + my $writable = -w $dbfile; + my $file_size = -s $dbfile; + + warn "[SQLite] File size: " . ($file_size || 0) . " bytes\n"; + warn "[SQLite] Readable: " . ($readable ? "YES" : "NO") . "\n"; + warn "[SQLite] Writable: " . ($writable ? "YES" : "NO") . "\n"; + + if (!$readable) { + warn "[SQLite] WARNING: Database file is not readable\n"; + warn "[SQLite] - Check file permissions: chmod 644 $dbfile\n"; + } + if (!$writable) { + warn "[SQLite] WARNING: Database file is not writable\n"; + warn "[SQLite] - Check file permissions: chmod 644 $dbfile\n"; + } + + if ($file_size == 0) { + warn "[SQLite] WARNING: Database file is empty (0 bytes)\n"; + warn "[SQLite] - This may be a newly created file\n"; + warn "[SQLite] - You may need to run the database setup script\n"; + } + } else { + warn "[SQLite] Database file DOES NOT EXIST\n"; + warn "[SQLite] - SQLite will create a new empty database file\n"; + warn "[SQLite] - You will need to run the database setup script to create tables\n"; + + # Check if directory is writable + my $dir = $dbfile; + $dir =~ s/[^\/]+$//; + $dir = '.' if $dir eq ''; + + if (!-w $dir) { + warn "[SQLite] ERROR: Directory '$dir' is not writable\n"; + warn "[SQLite] - Cannot create database file\n"; + warn "[SQLite] - Check directory permissions\n"; + } + } + + eval { + $self->{dbi} = DBI->connect( + "dbi:SQLite:dbname=$dbfile", + "", + "", + { RaiseError => 1, AutoCommit => 1, PrintError => 0 } + ); + }; + + if ($@) { + my $error = $@; + warn "[SQLite] Connection FAILED: $error\n"; + warn "[SQLite] Diagnostics:\n"; + warn "[SQLite] - Database file: $dbfile\n"; + warn "[SQLite] - Absolute path: $abs_dbfile\n"; + warn "[SQLite] - File exists: " . ($file_exists ? "YES" : "NO") . "\n"; + warn "[SQLite] Troubleshooting suggestions:\n"; + warn "[SQLite] 1. Check file permissions: ls -la $dbfile\n"; + warn "[SQLite] 2. Verify directory is writable\n"; + warn "[SQLite] 3. Check disk space: df -h\n"; + warn "[SQLite] 4. Ensure DBD::SQLite module is installed: cpan DBD::SQLite\n"; + die "SQLite connection failed: $error"; + } + + if (!$self->{dbi}) { + warn "[SQLite] Connection FAILED: $DBI::errstr\n"; + die "Can't connect to SQLite: $DBI::errstr"; + } + + # Log successful connection + warn "[SQLite] Connection SUCCESSFUL\n"; + + # Get and log basic database statistics + eval { + my $tables = $self->{dbi}->selectall_arrayref( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ); + my $table_count = scalar @$tables; + warn "[SQLite] Database contains $table_count table(s)\n"; + + if ($table_count == 0) { + warn "[SQLite] WARNING: Database appears to be empty (no tables found)\n"; + warn "[SQLite] - You need to run the database setup script\n"; + warn "[SQLite] - Check for schema initialization files\n"; + } else { + # Log table names and row counts for diagnostics + my @table_names = map { $_->[0] } @$tables; + warn "[SQLite] Tables: " . join(', ', @table_names) . "\n"; + + # Check for expected tables and get row counts + my %tables_hash = map { $_ => 1 } @table_names; + my @expected = qw(ircLink image quote); + my @missing; + foreach my $expected_table (@expected) { + if ($tables_hash{$expected_table}) { + my ($count) = $self->{dbi}->selectrow_array( + "SELECT COUNT(*) FROM \"$expected_table\"" + ); + warn "[SQLite] - $expected_table: $count row(s)\n"; + + if ($count == 0) { + warn "[SQLite] WARNING: Table '$expected_table' is empty\n"; + } + } else { + push @missing, $expected_table; + } + } + + if (@missing) { + warn "[SQLite] WARNING: Missing expected tables: " . join(', ', @missing) . "\n"; + warn "[SQLite] - Database may not be fully initialized\n"; + } + } + }; + if ($@) { + warn "[SQLite] Could not retrieve database statistics: $@\n"; + } } sub quote_identifier { -- 2.51.2 From e0c8d342071658142f2d8c034b532cd143433d29 Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Fri, 9 Jan 2026 18:17:16 -0600 Subject: [PATCH 004/231] README updated --- README.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dcca1e8..2d78d99 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,83 @@ mysql -u tumble -p tumble < sql/schema.mysql See [docs/database_setup.md](docs/database_setup.md) for detailed instructions. +## Debugging and Logging + +Tumble provides comprehensive database logging to help troubleshoot connection issues and diagnose problems. + +### Automatic Database Diagnostics + +When the application starts, it automatically: + +- **Logs connection attempts** with database details (host, database name, file paths) +- **Verifies database health** by checking for expected tables (`ircLink`, `image`, `quote`) +- **Reports table statistics** including row counts for each table +- **Warns about issues** such as: + - Missing database files (SQLite) + - Empty databases (no tables) + - Missing expected tables + - Empty tables (no data) + - File permission problems (SQLite) + - Connection failures with troubleshooting suggestions + +### Debug Mode + +For verbose logging of all database operations, enable debug mode: + +```bash +export TUMBLE_DEBUG=1 +``` + +With debug mode enabled, you'll see: +- All SQL queries being executed +- Row counts for each query result +- Detailed query execution information + +**Example:** +```bash +# Enable debug mode +export TUMBLE_DEBUG=1 + +# Start your web server or run the application +perl -I htdocs/lib htdocs/index.cgi +``` + +### Log Output Examples + +**Successful MySQL connection:** +``` +[MySQL] Attempting to connect to database 'tumble' on host 'localhost' as user 'tumble' +[MySQL] Connection SUCCESSFUL +[MySQL] Database contains 3 table(s) +[MySQL] Tables: ircLink, image, quote +``` + +**SQLite with missing database file:** +``` +[SQLite] Attempting to connect to database file: tumble.db +[SQLite] Database file DOES NOT EXIST +[SQLite] - SQLite will create a new empty database file +[SQLite] - You will need to run the database setup script to create tables +[SQLite] Connection SUCCESSFUL +[SQLite] WARNING: Database appears to be empty (no tables found) +[SQLite] - You need to run the database setup script +``` + +**Connection failure with diagnostics:** +``` +[MySQL] Connection FAILED: Access denied for user 'tumble'@'localhost' +[MySQL] Diagnostics: +[MySQL] - DSN: dbi:mysql:tumble;host=localhost +[MySQL] - Username: tumble +[MySQL] Troubleshooting suggestions: +[MySQL] 1. Verify MySQL server is running: systemctl status mysql +[MySQL] 2. Check credentials in config.yaml are correct +[MySQL] 3. Verify user has permissions: GRANT ALL ON tumble.* TO 'tumble'@'localhost' +``` + ## Bugs * fix user-agent being hardy for link verification - * Should warn if unable to talk to databse or database is empty * abstract quantity of items to be in 'hot shit' category * Fix odd encoding bugs for web site titles * Probably lots of others, but it has been in production for 10 years. -- 2.51.2 From 20046ebea77739146dcc51bda9b1199f60ad1343 Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Fri, 9 Jan 2026 23:11:47 -0600 Subject: [PATCH 005/231] Remove twit-link as it's not used --- scripts/twit-link.go | 47 -------------------------------------------- 1 file changed, 47 deletions(-) delete mode 100644 scripts/twit-link.go diff --git a/scripts/twit-link.go b/scripts/twit-link.go deleted file mode 100644 index f685ab2..0000000 --- a/scripts/twit-link.go +++ /dev/null @@ -1,47 +0,0 @@ -/************************* - This is a quick utility designed to take a uri to a tweet and render it as a twitter-looking tweet. - - Author: stahnma - Email: stahnma@websages.com - License: Apache 2 - - Notes: - Takes a uri like https://twitter.com/stahnma/status/452133159329476608 -*************************/ -package main - -import "encoding/json" -import "fmt" -import "io/ioutil" -import "net/http" -import "regexp" -import "os" -import "strings" - -func main() { - if len(os.Args) <= 1 { - // No argument passed - os.Exit(1) - } - input := os.Args[1] - matched, err := regexp.MatchString("twitter.com*", input) - if matched == false { - // Not a twitter uri - os.Exit(2) - } - parts := strings.Split(input, "/") - id := parts[len(parts)-1] - var f interface{} - - uri := "https://api.twitter.com/1/statuses/oembed.json?id=" + id - resp, err := http.Get(uri) - if err != nil { - fmt.Println("error:", err) - } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - err = json.Unmarshal(body, &f) - m := f.(map[string]interface{}) - html2 := m["html"].(string) - fmt.Println(html2) -} -- 2.51.2 From 2552cb12069ea3d4fee84b3b1ae25086fd1c8bac Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Fri, 9 Jan 2026 23:12:25 -0600 Subject: [PATCH 006/231] Add a flox env for development --- .flox/.gitattributes | 1 + .flox/.gitignore | 5 + .flox/env.json | 4 + .flox/env/manifest.lock | 548 ++++++++++++++++++++++++++++++++++++++++ .flox/env/manifest.toml | 107 ++++++++ 5 files changed, 665 insertions(+) create mode 100644 .flox/.gitattributes create mode 100644 .flox/.gitignore create mode 100644 .flox/env.json create mode 100644 .flox/env/manifest.lock create mode 100644 .flox/env/manifest.toml diff --git a/.flox/.gitattributes b/.flox/.gitattributes new file mode 100644 index 0000000..bb5491e --- /dev/null +++ b/.flox/.gitattributes @@ -0,0 +1 @@ +env/manifest.lock linguist-generated=true linguist-language=JSON diff --git a/.flox/.gitignore b/.flox/.gitignore new file mode 100644 index 0000000..8d21186 --- /dev/null +++ b/.flox/.gitignore @@ -0,0 +1,5 @@ +run/ +cache/ +lib/ +log/ +!env/ diff --git a/.flox/env.json b/.flox/env.json new file mode 100644 index 0000000..1e27ed0 --- /dev/null +++ b/.flox/env.json @@ -0,0 +1,4 @@ +{ + "name": "tumble", + "version": 1 +} diff --git a/.flox/env/manifest.lock b/.flox/env/manifest.lock new file mode 100644 index 0000000..014422a --- /dev/null +++ b/.flox/env/manifest.lock @@ -0,0 +1,548 @@ +{ + "lockfile-version": 1, + "manifest": { + "version": 1, + "install": { + "gnumake": { + "pkg-path": "gnumake" + }, + "go": { + "pkg-path": "go" + }, + "jq": { + "pkg-path": "jq" + }, + "sqlite": { + "pkg-path": "sqlite" + } + }, + "hook": {}, + "profile": {}, + "options": {} + }, + "packages": [ + { + "attr_path": "gnumake", + "broken": false, + "derivation": "/nix/store/58x1glcig8inznfv5sdrigljk4rn4lb0-gnumake-4.4.1.drv", + "description": "Tool to control the generation of non-source files from sources", + "install_id": "gnumake", + "license": "GPL-3.0-or-later", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "gnumake-4.4.1", + "pname": "gnumake", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T02:56:34.636323Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "4.4.1", + "outputs_to_install": [ + "man", + "out" + ], + "outputs": { + "info": "/nix/store/mmvr4hwsambcigjihl7lf3aiprhkdcd2-gnumake-4.4.1-info", + "man": "/nix/store/b5lxm0lk6x1bix95n07cz810qfli84c5-gnumake-4.4.1-man", + "out": "/nix/store/p15ia8pn2wxzdqaf07nrwdspfwsyrzyn-gnumake-4.4.1" + }, + "system": "aarch64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "gnumake", + "broken": false, + "derivation": "/nix/store/sklna5kvkyxs68s80xnyggz1z8b702b6-gnumake-4.4.1.drv", + "description": "Tool to control the generation of non-source files from sources", + "install_id": "gnumake", + "license": "GPL-3.0-or-later", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "gnumake-4.4.1", + "pname": "gnumake", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T03:12:55.964700Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "4.4.1", + "outputs_to_install": [ + "man", + "out" + ], + "outputs": { + "debug": "/nix/store/nzgvdhdnqcg0bpcq4k2aa2zd5qmwgg9x-gnumake-4.4.1-debug", + "info": "/nix/store/ij1n1xja5ixfhv4fqi5j8xlkbc80n1lc-gnumake-4.4.1-info", + "man": "/nix/store/3nailcskmiij9a4hb6dihaf0hqsfy7kg-gnumake-4.4.1-man", + "out": "/nix/store/644anc4jyqal79d739mwr5bxznb1i5qx-gnumake-4.4.1" + }, + "system": "aarch64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "gnumake", + "broken": false, + "derivation": "/nix/store/2v7zdb79wj91xd2pgpm3hs7bvc16w62i-gnumake-4.4.1.drv", + "description": "Tool to control the generation of non-source files from sources", + "install_id": "gnumake", + "license": "GPL-3.0-or-later", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "gnumake-4.4.1", + "pname": "gnumake", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T03:23:31.176502Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "4.4.1", + "outputs_to_install": [ + "man", + "out" + ], + "outputs": { + "info": "/nix/store/rbyz3pnr7n8wprrhy588siv2csisr5va-gnumake-4.4.1-info", + "man": "/nix/store/rv8sy3pwwdn90a5vzk47rm5rx8q040dc-gnumake-4.4.1-man", + "out": "/nix/store/w1zxhygdjxaff1rrp6na4qkh9x4aks8p-gnumake-4.4.1" + }, + "system": "x86_64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "gnumake", + "broken": false, + "derivation": "/nix/store/c8z2mcy4jhgpzm02s88gz34n8qa2k2ix-gnumake-4.4.1.drv", + "description": "Tool to control the generation of non-source files from sources", + "install_id": "gnumake", + "license": "GPL-3.0-or-later", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "gnumake-4.4.1", + "pname": "gnumake", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T03:33:07.336940Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "4.4.1", + "outputs_to_install": [ + "man", + "out" + ], + "outputs": { + "debug": "/nix/store/sg196gmr26wc93qj8ajzr5nw5hy54hfq-gnumake-4.4.1-debug", + "info": "/nix/store/mcbi0v4cy602a08rkaxl3w3r3k1qmds2-gnumake-4.4.1-info", + "man": "/nix/store/9l7klscdz1cf1wdhm6ihd0as6dxlxg72-gnumake-4.4.1-man", + "out": "/nix/store/n5m6p0j4anvf5cyy79nb2qn7smkzs176-gnumake-4.4.1" + }, + "system": "x86_64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "go", + "broken": false, + "derivation": "/nix/store/hgsy7836w5mmvbmfqq85ahvaijxp6hg5-go-1.25.5.drv", + "description": "Go Programming language", + "install_id": "go", + "license": "BSD-3-Clause", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "go-1.25.5", + "pname": "go", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T02:56:34.644816Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "1.25.5", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/fy2aq0rg6vbiyjfdpqy3429hqsan7iwm-go-1.25.5" + }, + "system": "aarch64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "go", + "broken": false, + "derivation": "/nix/store/szsqy070dn220802p3xz4qzayg21v51h-go-1.25.5.drv", + "description": "Go Programming language", + "install_id": "go", + "license": "BSD-3-Clause", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "go-1.25.5", + "pname": "go", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T03:12:55.979388Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "1.25.5", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/30d96229dgrh6n6bmd4hn9r1hcngj9bn-go-1.25.5" + }, + "system": "aarch64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "go", + "broken": false, + "derivation": "/nix/store/h6nns7plcgc96n5d5fx6d3gaimdb5ji1-go-1.25.5.drv", + "description": "Go Programming language", + "install_id": "go", + "license": "BSD-3-Clause", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "go-1.25.5", + "pname": "go", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T03:23:31.185201Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "1.25.5", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/wzakp2xy6if4n2b4fb1fkdjwilwlpifk-go-1.25.5" + }, + "system": "x86_64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "go", + "broken": false, + "derivation": "/nix/store/zl4wd83m32ygpjcljdl5wqsn0fq9093j-go-1.25.5.drv", + "description": "Go Programming language", + "install_id": "go", + "license": "BSD-3-Clause", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "go-1.25.5", + "pname": "go", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T03:33:07.351789Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "1.25.5", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/60z37432vmgkg54krwr1z057bqwp7583-go-1.25.5" + }, + "system": "x86_64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "jq", + "broken": false, + "derivation": "/nix/store/xh7sg4v2x4d44yzvmx9zcr71gn7z6b03-jq-1.8.1.drv", + "description": "Lightweight and flexible command-line JSON processor", + "install_id": "jq", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "jq-1.8.1", + "pname": "jq", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T02:56:48.963012Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "1.8.1", + "outputs_to_install": [ + "bin", + "man" + ], + "outputs": { + "bin": "/nix/store/9rm6fm3zq1jq8rgsx528cw8wkmfya2gf-jq-1.8.1-bin", + "dev": "/nix/store/5camppj4hz2mgkdbxs0kr6nvh6qa65wf-jq-1.8.1-dev", + "doc": "/nix/store/lak094rhhxlaj1qycadmxyfphgjadj5r-jq-1.8.1-doc", + "man": "/nix/store/cv999saj62xhq7xv5i7q6944vljykfmw-jq-1.8.1-man", + "out": "/nix/store/g371yvjasdr552v98p5kav7n35s1dfib-jq-1.8.1" + }, + "system": "aarch64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "jq", + "broken": false, + "derivation": "/nix/store/900w59hvyx6q26igzlgk2pn2wjx0fsss-jq-1.8.1.drv", + "description": "Lightweight and flexible command-line JSON processor", + "install_id": "jq", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "jq-1.8.1", + "pname": "jq", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T03:13:15.277271Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "1.8.1", + "outputs_to_install": [ + "bin", + "bin", + "man" + ], + "outputs": { + "bin": "/nix/store/m8qv4g54q3jmjb8i33v9lljcwhydx2vd-jq-1.8.1-bin", + "dev": "/nix/store/5ykn83b3hhvnnq0p5vqgcrzihrl9wpsl-jq-1.8.1-dev", + "doc": "/nix/store/37ypy1595g6rj3cymh1mpk2b25fx40g7-jq-1.8.1-doc", + "man": "/nix/store/9x2457g76jikfy7xq4mjqwzl8iz3zvxj-jq-1.8.1-man", + "out": "/nix/store/16lg603jzppwjanlakcak1ais69mkd03-jq-1.8.1" + }, + "system": "aarch64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "jq", + "broken": false, + "derivation": "/nix/store/cajf8n8zpw65sd9ch7av6lwwb2qkvx8v-jq-1.8.1.drv", + "description": "Lightweight and flexible command-line JSON processor", + "install_id": "jq", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "jq-1.8.1", + "pname": "jq", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T03:23:45.825223Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "1.8.1", + "outputs_to_install": [ + "bin", + "man" + ], + "outputs": { + "bin": "/nix/store/kkb17whpkdrmn9g3gk7y6l69vipxsw0i-jq-1.8.1-bin", + "dev": "/nix/store/lypnqs272644l8ff6wfji9rg5jw10v7h-jq-1.8.1-dev", + "doc": "/nix/store/nyw97c4pywfcqqap5hyk9xjghczlbshl-jq-1.8.1-doc", + "man": "/nix/store/iwr61wi83kflqvz8j5nf7ridaqq6nh2w-jq-1.8.1-man", + "out": "/nix/store/ri930a557685c64bdh88a5031i7hx3vy-jq-1.8.1" + }, + "system": "x86_64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "jq", + "broken": false, + "derivation": "/nix/store/ig0anfgqf7v924r7djvvp8vi46r74g5k-jq-1.8.1.drv", + "description": "Lightweight and flexible command-line JSON processor", + "install_id": "jq", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "jq-1.8.1", + "pname": "jq", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T03:33:27.397602Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "1.8.1", + "outputs_to_install": [ + "bin", + "bin", + "man" + ], + "outputs": { + "bin": "/nix/store/zssasryipb2x4gk2ahzacl4mvvcmk48j-jq-1.8.1-bin", + "dev": "/nix/store/rmxxm5jnxq93kvkhbr2b3hzj6v3ldp8z-jq-1.8.1-dev", + "doc": "/nix/store/mkhfvc69grlky3iblibkw9wcc12jcdqq-jq-1.8.1-doc", + "man": "/nix/store/7d4pv1iymyqk2lykwj1ydml3rjhc6gl3-jq-1.8.1-man", + "out": "/nix/store/807g765zgpmp1c8fm5y40rw2gbr1k6dk-jq-1.8.1" + }, + "system": "x86_64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "sqlite", + "broken": false, + "derivation": "/nix/store/ld25n61svsdllkxxnkazbvl2s9q9hc47-sqlite-3.51.1.drv", + "description": "Self-contained, serverless, zero-configuration, transactional SQL database engine", + "install_id": "sqlite", + "license": "Public Domain", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "sqlite-3.51.1", + "pname": "sqlite", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T02:58:15.392713Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "3.51.1", + "outputs_to_install": [ + "bin", + "man" + ], + "outputs": { + "bin": "/nix/store/a186f18sdws3qklx5va41s7ifw7bsb00-sqlite-3.51.1-bin", + "dev": "/nix/store/x41hb0yp57sld90svdj6avg6wkpjh49w-sqlite-3.51.1-dev", + "doc": "/nix/store/y9v5vg60im2d4pg26d21f89lkfsnkbwc-sqlite-3.51.1-doc", + "man": "/nix/store/gkzxfzbwrhhg2ravkl9dkjdsg513a8mk-sqlite-3.51.1-man", + "out": "/nix/store/6yawzw96lhv44d6rfkk8l5k22srfc81q-sqlite-3.51.1" + }, + "system": "aarch64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "sqlite", + "broken": false, + "derivation": "/nix/store/11v3vfn6vv1xba999i4lgf0qi1s239pp-sqlite-3.51.1.drv", + "description": "Self-contained, serverless, zero-configuration, transactional SQL database engine", + "install_id": "sqlite", + "license": "Public Domain", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "sqlite-3.51.1", + "pname": "sqlite", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T03:15:12.326665Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "3.51.1", + "outputs_to_install": [ + "bin", + "man" + ], + "outputs": { + "bin": "/nix/store/9prciviq1v39c1xfg7zz1dnh3gf54071-sqlite-3.51.1-bin", + "debug": "/nix/store/hjycafslwds8364ykxrcn98fm34pznq0-sqlite-3.51.1-debug", + "dev": "/nix/store/ayxchn2mch6cx8kjapnffsp0rpg63z8i-sqlite-3.51.1-dev", + "doc": "/nix/store/kkq2w59ryaiwjfc1ffrd3pf7s5pkfbm9-sqlite-3.51.1-doc", + "man": "/nix/store/v0i0f74zf9ap6lm6gyi500hx04pg4lkv-sqlite-3.51.1-man", + "out": "/nix/store/kg79jrqc5cpf4y8xdq4skxfhwnwkv4nq-sqlite-3.51.1" + }, + "system": "aarch64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "sqlite", + "broken": false, + "derivation": "/nix/store/a0iv2zzs04ndy8pq29rqgzwv0bh7lp7j-sqlite-3.51.1.drv", + "description": "Self-contained, serverless, zero-configuration, transactional SQL database engine", + "install_id": "sqlite", + "license": "Public Domain", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "sqlite-3.51.1", + "pname": "sqlite", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T03:25:12.699503Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "3.51.1", + "outputs_to_install": [ + "bin", + "man" + ], + "outputs": { + "bin": "/nix/store/yiflnvqlacl3m8dwk32v2jkcvk880db9-sqlite-3.51.1-bin", + "dev": "/nix/store/7mkpb5j5am0kb1x4bslpr4wkfnpa2ifa-sqlite-3.51.1-dev", + "doc": "/nix/store/yxpcziz52mnywkp0bdv8hfll6zp0hjxz-sqlite-3.51.1-doc", + "man": "/nix/store/3ds853wgkqb16ak9bw5dz2dh3529i7j4-sqlite-3.51.1-man", + "out": "/nix/store/3s77d97c92iwc8vka16ax5fdw0hq82bk-sqlite-3.51.1" + }, + "system": "x86_64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "sqlite", + "broken": false, + "derivation": "/nix/store/l9p90hm8zslgx0y076nfi30jijkk15fi-sqlite-3.51.1.drv", + "description": "Self-contained, serverless, zero-configuration, transactional SQL database engine", + "install_id": "sqlite", + "license": "Public Domain", + "locked_url": "https://github.com/flox/nixpkgs?rev=5912c1772a44e31bf1c63c0390b90501e5026886", + "name": "sqlite-3.51.1", + "pname": "sqlite", + "rev": "5912c1772a44e31bf1c63c0390b90501e5026886", + "rev_count": 923638, + "rev_date": "2026-01-07T06:26:47Z", + "scrape_date": "2026-01-08T03:35:32.372335Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "3.51.1", + "outputs_to_install": [ + "bin", + "bin", + "man" + ], + "outputs": { + "bin": "/nix/store/lbm0vi6xqrmvin5a9cj7v69qc5ayg5q0-sqlite-3.51.1-bin", + "debug": "/nix/store/az4zy215sxq147n227i99fgggzj9cqpy-sqlite-3.51.1-debug", + "dev": "/nix/store/byl3w16n1zd8rnx040afcyy8zhn996w1-sqlite-3.51.1-dev", + "doc": "/nix/store/ki6c1dpgsijg715nkgciyg3zsksixyq4-sqlite-3.51.1-doc", + "man": "/nix/store/943jfmi9yq1yva0c1d50p9pzmssg47c6-sqlite-3.51.1-man", + "out": "/nix/store/f6zwd0xdld51287as0sv79kbaf2pcayh-sqlite-3.51.1" + }, + "system": "x86_64-linux", + "group": "toplevel", + "priority": 5 + } + ] +} diff --git a/.flox/env/manifest.toml b/.flox/env/manifest.toml new file mode 100644 index 0000000..6266f6c --- /dev/null +++ b/.flox/env/manifest.toml @@ -0,0 +1,107 @@ +## Flox Environment Manifest ----------------------------------------- +## +## _Everything_ you need to know about the _manifest_ is here: +## +## https://flox.dev/docs/reference/command-reference/manifest.toml/ +## +## ------------------------------------------------------------------- +# Flox manifest version managed by Flox CLI +version = 1 + + +## Install Packages -------------------------------------------------- +## $ flox install gum <- puts a package in [install] section below +## $ flox search gum <- search for a package +## $ flox show gum <- show all versions of a package +## ------------------------------------------------------------------- +[install] +gnumake.pkg-path = "gnumake" +jq.pkg-path = "jq" +go.pkg-path = "go" +sqlite.pkg-path = "sqlite" +# gum.pkg-path = "gum" +# gum.version = "^0.14.5" + + +## Environment Variables --------------------------------------------- +## ... available for use in the activated environment +## as well as [hook], [profile] scripts and [services] below. +## ------------------------------------------------------------------- +[vars] +# INTRO_MESSAGE = "It's gettin' Flox in here" + + +## Activation Hook --------------------------------------------------- +## ... run by _bash_ shell when you run 'flox activate'. +## ------------------------------------------------------------------- +[hook] +# on-activate = ''' +# # -> Set variables, create files and directories +# # -> Perform initialization steps, e.g. create a python venv +# # -> Useful environment variables: +# # - FLOX_ENV_PROJECT=/home/user/example +# # - FLOX_ENV=/home/user/example/.flox/run +# # - FLOX_ENV_CACHE=/home/user/example/.flox/cache +# ''' + + +## Profile script ---------------------------------------------------- +## ... sourced by _your shell_ when you run 'flox activate'. +## ------------------------------------------------------------------- +[profile] +# common = ''' +# gum style \ +# --foreground 212 --border-foreground 212 --border double \ +# --align center --width 50 --margin "1 2" --padding "2 4" \ +# $INTRO_MESSAGE +# ''' +## Shell-specific customizations such as setting aliases go here: +# bash = ... +# zsh = ... +# fish = ... + + +## Services --------------------------------------------------------- +## $ flox services start <- Starts all services +## $ flox services status <- Status of running services +## $ flox activate --start-services <- Activates & starts all +## ------------------------------------------------------------------ +[services] +# myservice.command = "python3 -m http.server" + + +## Include ---------------------------------------------------------- +## ... environments to create a composed environment +## ------------------------------------------------------------------ +[include] +# environments = [ +# { dir = "../common" } +# ] + + +## Build and publish your own packages ------------------------------ +## $ flox build +## $ flox publish +## ------------------------------------------------------------------ +[build] +# [build.myproject] +# description = "The coolest project ever" +# version = "0.0.1" +# command = """ +# mkdir -p $out/bin +# cargo build --release +# cp target/release/myproject $out/bin/myproject +# """ + + +## Other Environment Options ----------------------------------------- +[options] +# Systems that environment is compatible with +# systems = [ +# "aarch64-darwin", +# "aarch64-linux", +# "x86_64-darwin", +# "x86_64-linux", +# ] +# Uncomment to disable CUDA detection. +# cuda-detection = false -- 2.51.2 From cf180e9ae4d4da8fa8ee7f287b0651fb30c131a2 Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Fri, 9 Jan 2026 23:15:29 -0600 Subject: [PATCH 007/231] Remove older packaging artifacts --- debian/changelog.in | 6 ---- debian/compat | 1 - debian/control | 14 -------- debian/fixshebang.sh | 37 --------------------- debian/rules | 16 --------- tumble.spec | 77 -------------------------------------------- 6 files changed, 151 deletions(-) delete mode 100644 debian/changelog.in delete mode 100644 debian/compat delete mode 100644 debian/control delete mode 100644 debian/fixshebang.sh delete mode 100755 debian/rules delete mode 100644 tumble.spec diff --git a/debian/changelog.in b/debian/changelog.in deleted file mode 100644 index a9ca207..0000000 --- a/debian/changelog.in +++ /dev/null @@ -1,6 +0,0 @@ -tumble (==VERSION==-1) stable unstable testing; urgency=low - - * Initial package - - -- Michael Stahnke Thu, 27 Mar 2014 21:35:39 +0000 - diff --git a/debian/compat b/debian/compat deleted file mode 100644 index 7f8f011..0000000 --- a/debian/compat +++ /dev/null @@ -1 +0,0 @@ -7 diff --git a/debian/control b/debian/control deleted file mode 100644 index 253760d..0000000 --- a/debian/control +++ /dev/null @@ -1,14 +0,0 @@ -Source: tumble -Section: web -Priority: optional -Maintainer: Michael Stahnke -Build-Depends: cdbs, debhelper (>> 7) -Build-Depends-Indep: perl -Standards-Version: 3.8.4 -Homepage: http://github.com/websages/tumble - -Package: tumble -Architecture: all -Depends: ${shlibs:Depends}, ${misc:Depends}, libcrypt-ssleay-perl, apache2, libapache2-mod-perl2, libdbd-mysql-perl, libcgi-application-perl -Description: Web application used by websages. - A classic tumblelog written in Perl in something like 2004. diff --git a/debian/fixshebang.sh b/debian/fixshebang.sh deleted file mode 100644 index 3ead0ac..0000000 --- a/debian/fixshebang.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash - -for target_dir in $@ -do - for f in `find "$target_dir" -type f 2>/dev/null` - do - textflag=0 - filetype="`file -b $f`" - for ft in $filetype - do - if [ "${#ft}" -lt 4 ] - then - continue - fi - if [ "${ft:0:4}" == "text" ] - then - textflag=1 - break - fi - done - - if [ "$textflag" -eq 0 ] - then - continue - fi - - cp -pf $f $f.tmp - sed -e '1,1s,^#![ ]*\([^ ]*\)/\(ruby\|env ruby\)$,#!/opt/puppet/bin/ruby,' \ - -e '1,1s,^#![ ]*\([^ ]*\)/\(wish\|perl\)$,#!/usr/bin/\2,' < $f > $f.tmp - if ! cmp $f $f.tmp >/dev/null - then - mv -f $f.tmp $f - else - rm -f $f.tmp - fi - done -done diff --git a/debian/rules b/debian/rules deleted file mode 100755 index 0e60b5c..0000000 --- a/debian/rules +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/make -f - -include /usr/share/cdbs/1/rules/debhelper.mk -include /usr/share/cdbs/1/rules/buildcore.mk - - -binary-install/tumble:: - env DESTDIR=$(CURDIR)/debian/$(cdbs_curpkg) make install -# for file in `find $(CURDIR)/debian/tmp -type f`; do; echo $$file ; sed -i -e '1s,^#!.*perl,#!%{_bindir}/perl,' $$file; done - -binary-post-install/tumble:: - bash $(CURDIR)/debian/fixshebang.sh \ - '$(CURDIR)/debian/$(cdbs_curpkg)/srv/www' - - -clean:: diff --git a/tumble.spec b/tumble.spec deleted file mode 100644 index 1b425e8..0000000 --- a/tumble.spec +++ /dev/null @@ -1,77 +0,0 @@ - -Name: tumble -Version: ==VERSION== -Release: 1%{?dist} -Summary: A classic tumblelog application. - -Group: Internet/Applications -License: ASL 2.0 -URL: http://tumble.wcyd.org -Source0: %{name}-%{version}.tar.gz -BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-%(id -un) - -Requires: perl(DBD::mysql) -Requires: httpd mod_perl -Requires: perl-CGI-Application -Requires: perl-Crypt-SSLeay -# Only when running on localhost, but that's what's hard-coded for now. -Requires: mysql-server -BuildRequires: httpd -#BuildRequires: golang -BuildArch: noarch - -%description -A classic tumblelog written in Perl in something like 2004. - -%prep -%setup -q -rm -rf debian - -%build - - -%install -rm -rf $RPM_BUILD_ROOT -make install DESTDIR=%{buildroot} - -# Fix shebang line of scripts -for file in `find $RPM_BUILD_ROOT -type f`; do -echo $file - sed -i -e '1s,^#!.*perl,#!%{_bindir}/perl,' $file -done - -mkdir -p %{buildroot}%{_sysconfdir}/tumble -pushd %{buildroot}/srv/www/%{name}/htdocs; ln -fs ../../../..%{_sysconfdir}/tumble/config.yaml .; popd -if /usr/sbin/httpd -v | head -1 | awk '{print $3}' | cut -d/ -f2 | grep 2.2 &> /dev/null ; then - cp -pr conf/tumble_22.conf %{buildroot}/%{_sysconfdir}/httpd/conf.d/tumble.conf -else - cp -pr conf/tumble_24.conf %{buildroot}/%{_sysconfdir}/httpd/conf.d -fi - - - -%files -%doc sql README.md -%dir %{_sysconfdir}/tumble -%config(noreplace)%{_sysconfdir}/tumble/* -%config(noreplace)%{_sysconfdir}/httpd/conf.d/* -/srv/www/%{name}/htdocs/2202 -/srv/www/%{name}/htdocs/*.png -/srv/www/%{name}/htdocs/buttons -/srv/www/%{name}/htdocs/css -/srv/www/%{name}/htdocs/favicon.ico -/srv/www/%{name}/htdocs/img -/srv/www/%{name}/htdocs/index.cgi -/srv/www/%{name}/htdocs/irclink -/srv/www/%{name}/htdocs/lib -/srv/www/%{name}/htdocs/quote -/srv/www/%{name}/htdocs/search.cgi -/srv/www/%{name}/htdocs/thtml -/srv/www/%{name}/htdocs/config.yaml -#/usr/local/bin/* -#%config(noreplace)%{_sysconfdir}/cron.hourly/* - - -%changelog -* Sun Oct 27 2013 - 1.0.0-1 -- First packaging -- 2.51.2 From 1c9adbed08de06394ca844744462cb5537bfde4b Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Sat, 10 Jan 2026 00:22:51 -0600 Subject: [PATCH 008/231] Remove apache config files --- conf/tumble_22.conf | 34 ---------------------------------- conf/tumble_24.conf | 31 ------------------------------- 2 files changed, 65 deletions(-) delete mode 100644 conf/tumble_22.conf delete mode 100644 conf/tumble_24.conf diff --git a/conf/tumble_22.conf b/conf/tumble_22.conf deleted file mode 100644 index aa0e089..0000000 --- a/conf/tumble_22.conf +++ /dev/null @@ -1,34 +0,0 @@ - - ServerName tumble.wcyd.org - ServerAlias tumble.wcyd.org tumble.loserfish.org - ServerAdmin admin@wcyd.org - DocumentRoot /srv/www/tumble.wcyd.org/htdocs - ErrorLog /var/log/apache2/tumble-error.log - CustomLog /var/log/apache2/tumble-access.log common - - - Options ExecCGI FollowSymLinks - AllowOverride None - AddHandler cgi-script .cgi - DirectoryIndex index.cgi - RewriteEngine On - RewriteBase / - RewriteRule ^index\.xml$ ?dtype=xml - RewriteRule ^search/(.*[^/])/?$ /search.cgi?search=$1 - Order allow,deny - Allow from all - - - - DirectoryIndex index.html - Order allow,deny - Allow from all - - - - DirectoryIndex index.html - Order allow,deny - Allow from all - - - diff --git a/conf/tumble_24.conf b/conf/tumble_24.conf deleted file mode 100644 index b49d8ee..0000000 --- a/conf/tumble_24.conf +++ /dev/null @@ -1,31 +0,0 @@ - - ServerName tumble - ServerAlias tumble.example.com - ServerAdmin admin@example.com - DocumentRoot /srv/www/tumble/htdocs - ErrorLog /var/log/httpd/tumble-error.log - CustomLog /var/log/httpd/tumble-access.log common - - - Options ExecCGI FollowSymLinks - AllowOverride None - AddHandler cgi-script .cgi - DirectoryIndex index.cgi - RewriteEngine On - RewriteBase / - RewriteRule ^index\.xml$ ?dtype=xml - RewriteRule ^search/(.*[^/])/?$ /search.cgi?search=$1 - Require all granted - - - - DirectoryIndex index.html - Require all granted - - - - DirectoryIndex index.html - Require all granted - - - -- 2.51.2 From 886c8f7752db65e44302fd56a72033e54995a3e9 Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Sat, 10 Jan 2026 00:25:10 -0600 Subject: [PATCH 009/231] checkpoint --- .gitignore | 4 +- Makefile | 100 ++---- cmd/tumble/main.go | 97 +++++ conf/config.yaml | 3 + go.mod | 21 ++ go.sum | 32 ++ internal/assets/buttons/button.cgi | 82 +++++ internal/assets/buttons/index.html | 56 +++ internal/assets/css/screen.css | 197 +++++++++++ internal/assets/favicon.ico | Bin 0 -> 318 bytes internal/assets/fs.go | 6 + internal/assets/img/next.jpg | Bin 0 -> 13711 bytes internal/assets/img/parens.gif | Bin 0 -> 197 bytes internal/assets/img/prev.jpg | Bin 0 -> 13699 bytes internal/config/config.go | 48 +++ internal/data/factory.go | 22 ++ internal/data/mysql.go | 263 ++++++++++++++ internal/data/schema.go | 6 + internal/data/schema.mysql | 53 +++ internal/data/schema.sqlite | 46 +++ internal/data/sqlite.go | 241 +++++++++++++ internal/data/store.go | 50 +++ internal/data/util.go | 17 + internal/handler/handlers.go | 332 ++++++++++++++++++ internal/handler/irclink.go | 104 ++++++ internal/handler/preview.go | 38 ++ internal/handler/quote.go | 31 ++ internal/service/content.go | 172 +++++++++ internal/templates/renderer.go | 35 ++ internal/templates/views/index.html | 168 +++++++++ internal/templates/views/index.xml | 17 + internal/templates/views/tumble_date.html | 5 + .../templates/views/tumble_item_image.html | 3 + .../templates/views/tumble_item_image.xml | 7 + .../templates/views/tumble_item_ircLink.html | 5 + .../templates/views/tumble_item_ircLink.xml | 7 + .../templates/views/tumble_item_quote.html | 4 + .../templates/views/tumble_item_quote.xml | 7 + .../templates/views/tumble_item_text.html | 3 + internal/templates/views/tumble_item_text.xml | 3 + .../templates/views/tumble_item_top5.html | 1 + internal/version/version.go | 6 + tests/add_link.sh | 16 + tests/add_quote.sh | 19 + tests/api_test.sh | 67 ++++ tests/load_fixtures.sh | 22 ++ thoughts | 11 + 47 files changed, 2354 insertions(+), 73 deletions(-) create mode 100644 cmd/tumble/main.go create mode 100644 conf/config.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100755 internal/assets/buttons/button.cgi create mode 100644 internal/assets/buttons/index.html create mode 100644 internal/assets/css/screen.css create mode 100755 internal/assets/favicon.ico create mode 100644 internal/assets/fs.go create mode 100644 internal/assets/img/next.jpg create mode 100644 internal/assets/img/parens.gif create mode 100644 internal/assets/img/prev.jpg create mode 100644 internal/config/config.go create mode 100644 internal/data/factory.go create mode 100644 internal/data/mysql.go create mode 100644 internal/data/schema.go create mode 100644 internal/data/schema.mysql create mode 100644 internal/data/schema.sqlite create mode 100644 internal/data/sqlite.go create mode 100644 internal/data/store.go create mode 100644 internal/data/util.go create mode 100644 internal/handler/handlers.go create mode 100644 internal/handler/irclink.go create mode 100644 internal/handler/preview.go create mode 100644 internal/handler/quote.go create mode 100644 internal/service/content.go create mode 100644 internal/templates/renderer.go create mode 100644 internal/templates/views/index.html create mode 100644 internal/templates/views/index.xml create mode 100644 internal/templates/views/tumble_date.html create mode 100644 internal/templates/views/tumble_item_image.html create mode 100644 internal/templates/views/tumble_item_image.xml create mode 100644 internal/templates/views/tumble_item_ircLink.html create mode 100644 internal/templates/views/tumble_item_ircLink.xml create mode 100644 internal/templates/views/tumble_item_quote.html create mode 100644 internal/templates/views/tumble_item_quote.xml create mode 100644 internal/templates/views/tumble_item_text.html create mode 100644 internal/templates/views/tumble_item_text.xml create mode 100644 internal/templates/views/tumble_item_top5.html create mode 100644 internal/version/version.go create mode 100755 tests/add_link.sh create mode 100755 tests/add_quote.sh create mode 100755 tests/api_test.sh create mode 100755 tests/load_fixtures.sh create mode 100644 thoughts diff --git a/.gitignore b/.gitignore index f5d924e..8054410 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -debian/changelog -scripts/twit-link +tumble.sqlite +bin/* diff --git a/Makefile b/Makefile index 7fce1a9..f0743ef 100644 --- a/Makefile +++ b/Makefile @@ -1,87 +1,45 @@ - PKGNAME=tumble -TMP_PATTERN:=$(shell mktemp -d -u -p . -t rpmbuild-XXXXXXX) -TMPDIR=$(shell pwd)/$(TMP_PATTERN) -TAR_TMP_DIR:=$(shell mktemp -d -u -t tarball-XXXXXXX) - -DATADIR=$(DESTDIR)/srv/www/$(PKGNAME) -CONFDIR=$(DESTDIR)/etc/ -CRON_DIR=$(CONFDIR)/cron.hourly -SPEC_FILE=$(PKGNAME).spec +VERSION=$(shell git describe --tags --always | sed -e 's/-/\./g') +BINARY_NAME=tumble +BUILD_DIR=bin -RPMBUILD := $(shell if test -f /usr/bin/rpmbuild ; then echo /usr/bin/rpmbuild ; else echo "x" ; fi) -RPM_DEFINES = --define "_specdir $(TMPDIR)/SPECS" --define "_rpmdir $(TMPDIR)/RPMS" --define "_sourcedir $(TMPDIR)/SOURCES" --define "_srcrpmdir $(TMPDIR)/SRPMS" --define "_builddir $(TMPDIR)/BUILD" -MAKE_DIRS= $(TMPDIR)/SPECS $(TMPDIR)/SOURCES $(TMPDIR)/BUILD $(TMPDIR)/SRPMS $(TMPDIR)/RPMS -VERSION=$(shell git describe | sed -e 's/-/\./g') -TARBALL=$(PKGNAME)-$(VERSION).tar.gz +.PHONY: all build clean test deps docs kill restart reset-db load-fixtures -DEBIAN :=$(shell test -f "/etc/debian_version" && echo 'debian' || echo 'x') +all: build -ifeq ($(DEBIAN), debian) -APACHE_DIR=$(CONFDIR)apache2/sites-available/ -else -APACHE_DIR=$(CONFDIR)httpd/conf.d -endif +deps: + go mod download +GIT_COMMIT=$(shell git rev-parse --short HEAD) +LDFLAGS=-ldflags "-X tumble/internal/version.CommitHash=$(GIT_COMMIT)" build: - #go build -o scripts/twit-link scripts/twit-link.go - - -install: - mkdir -p $(DATADIR) $(APACHE_DIR) $(CONFDIR)/$(PKGNAME) - install -p -m644 htdocs/config.yaml $(CONFDIR)/$(PKGNAME) - cp -pr htdocs $(DATADIR) - mkdir -p $(DESTDIR)/usr/local/bin - #go build -o scripts/twit-link scripts/twit-link.go - #cp -pr scripts/twit-link $(DESTDIR)/usr/local/bin + mkdir -p $(BUILD_DIR) + CGO_ENABLED=0 go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/tumble -tarball: clean - mkdir -p $(TAR_TMP_DIR)/$(PKGNAME)-$(VERSION) - cd ..; cp -pr $(PKGNAME)/* $(TAR_TMP_DIR)/$(PKGNAME)-$(VERSION) - cd $(TAR_TMP_DIR); tar pczf $(TARBALL) $(PKGNAME)-$(VERSION) - mv $(TAR_TMP_DIR)/$(TARBALL) . - rm -rf $(TAR_TMP_DIR) +clean: + rm -rf $(BUILD_DIR) -uninstall: - rm -rf $(DATADIR) - rm -rf $(APACHE_DIR)/$(PKGNAME).conf +test: + go test -v ./... -clean: - rm -f $(TARBALL) *.rpm - rm -f scripts/twit-link twit-link - rm -rf debian/changelog debian/$(PKGNAME)* debian/tmp debian/files - rm -rf BUILD SRPMS RPMS SPECS SOURCES - rm -rf ./rpmbuild-* ./tarball-* ./$(PKGNAME)*gz +test-api: build + ./tests/api_test.sh +docs: + @echo "Generating API docs..." + # Placeholder for Swagger/OpenAPI generation + # e.g. swag init -g cmd/tumble/main.go --output docs/api -srpm: tarball - @mkdir -p $(MAKE_DIRS) - cp -f $(TARBALL) $(TMPDIR)/SOURCES - cp -f $(SPEC_FILE) $(TMPDIR)/SPECS - sed -i 's/==VERSION==/$(VERSION)/g' $(TMPDIR)/SPECS/$(SPEC_FILE) - @wait - $(RPMBUILD) $(RPM_DEFINES) -bs $(TMPDIR)/SPECS/$(SPEC_FILE) - @mv -f $(TMPDIR)/SRPMS/* . - @rm -rf $(TMPDIR) +kill: + -pkill -f $(BUILD_DIR)/$(BINARY_NAME) -deb: - sed -e 's/==VERSION==/$(VERSION)/g' debian/changelog.in > debian/changelog - @wait - dpkg-buildpackage +restart: kill build + $(BUILD_DIR)/$(BINARY_NAME) conf/config.yaml & -rpm: clean tarball - @mkdir -p $(MAKE_DIRS) - cp -f $(TARBALL) $(TMPDIR)/SOURCES - cp -f $(SPEC_FILE) $(TMPDIR)/SPECS - sed -i 's/==VERSION==/$(VERSION)/g' $(TMPDIR)/SPECS/$(SPEC_FILE) - @wait - $(RPMBUILD) $(RPM_DEFINES) -ba $(TMPDIR)/SPECS/$(SPEC_FILE) - @mv -f $(TMPDIR)/RPMS/noarch/* . - @rm -rf $(TMPDIR) +reset-db: + rm -f tumble.sqlite -tempdir: - echo $(TMPDIR) +load-fixtures: + ./tests/load_fixtures.sh -test: - prove -l t/*.t diff --git a/cmd/tumble/main.go b/cmd/tumble/main.go new file mode 100644 index 0000000..f187df1 --- /dev/null +++ b/cmd/tumble/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + + "tumble/internal/assets" + "tumble/internal/config" + "tumble/internal/data" + "tumble/internal/handler" + "tumble/internal/service" + "tumble/internal/templates" +) + +func main() { + // Load Config + cfgPath := "conf/config.yaml" // Default or flag + if len(os.Args) > 1 { + cfgPath = os.Args[1] + } + + cfg, err := config.Load(cfgPath) + if err != nil { + log.Printf("Warning: Could not load config from %s: %v", cfgPath, err) + // Proceed with defaults or fail? Perl requires config.yaml in htdocs usually. + // We'll assume we need one. + // Try htdocs/config.yaml + cfg, err = config.Load("htdocs/config.yaml") + if err != nil { + log.Fatalf("Fatal: Could not load config: %v", err) + } + } + + // Init DB + store, err := data.NewStore(cfg.Driver, cfg.DSN()) + if err != nil { + log.Fatalf("Fatal: Could not connect to DB: %v", err) + } + defer store.Close() + + // Auto-Migrate + if err := store.Bootstrap(context.TODO()); err != nil { + log.Fatalf("Fatal: Database bootstrap failed: %v", err) + } + + // Init Service + svc := service.NewContentService(cfg) + + // Init Renderer + renderer, err := templates.NewRenderer() + if err != nil { + log.Fatalf("Fatal: Could not init renderer: %v", err) + } + + // Init Handler + h := handler.NewHandler(cfg, store, svc, renderer) + + // Router + mux := http.NewServeMux() + + // Main Routes + mux.HandleFunc("/", h.Index) + mux.HandleFunc("/index.cgi", h.Index) + mux.HandleFunc("/search.cgi", h.Search) + mux.HandleFunc("/irclink/", h.IRCLinkHandler) // Handles /irclink/?id and posts + mux.HandleFunc("/ogpreview.cgi", h.OGPreviewHandler) + + // v0 Routes (Aliased) + mux.HandleFunc("/v0/", h.Index) + mux.HandleFunc("/v0/index.cgi", h.Index) + mux.HandleFunc("/v0/search.cgi", h.Search) + mux.HandleFunc("/v0/irclink/", h.IRCLinkHandler) + mux.HandleFunc("/v0/ogpreview.cgi", h.OGPreviewHandler) + mux.HandleFunc("/v0/quote/", h.QuoteHandler) + + // Quote Handler (Legacy) + mux.HandleFunc("/quote/", h.QuoteHandler) + mux.HandleFunc("/quote/index.cgi", h.QuoteHandler) + + // Static Assets + // Serve from embedded FS + // "/css/" -> internal/assets/css + fileServer := http.FileServer(http.FS(assets.StaticFS)) + mux.Handle("/css/", fileServer) + mux.Handle("/img/", fileServer) + mux.Handle("/buttons/", fileServer) + mux.Handle("/favicon.ico", fileServer) + + // Start + addr := ":8080" // Default or from config? Perl was CGI so port wasn't in config. + log.Printf("Starting tumble server on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("Server failed: %v", err) + } +} diff --git a/conf/config.yaml b/conf/config.yaml new file mode 100644 index 0000000..f4cd021 --- /dev/null +++ b/conf/config.yaml @@ -0,0 +1,3 @@ +driver: sqlite +database: tumble.sqlite +baseurl: localhost:8080 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dbd6afd --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module tumble + +go 1.25.5 + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.33 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.43.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3628979 --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA= +modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= diff --git a/internal/assets/buttons/button.cgi b/internal/assets/buttons/button.cgi new file mode 100755 index 0000000..3e2d16e --- /dev/null +++ b/internal/assets/buttons/button.cgi @@ -0,0 +1,82 @@ +#!/usr/bin/perl -w + +use CGI; + +use YAML qw( LoadFile ); + +use strict; + +my $cgi = new CGI; + +my $user = $cgi->param( 'user' ); + +my $config = LoadFile( '../config.yaml' ); +my $url = $config->{'baseurl'}; + +if ( $user ) { + print "Content-type: text/html\n\n"; + + print qq( + + + + tumblefish buttons + + + + + +
+
+ tumblefish. +
+ +
+
+
!!
+
buttons
+
yay!
+
+
+
+ So how do I install this crap?? +
+
+ -
{{.Container}}
+
+ {{if .Poster}} +
+ Filter: + All | + Links | + Quotes +
+ {{end}} + {{.Container}} +
-- 2.51.2 From 8cc30289a0319bce031fbc86fded7bf9deee1ebd Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Fri, 16 Jan 2026 20:33:29 -0600 Subject: [PATCH 040/231] Update flox env --- .flox/env/manifest.lock | 289 +++++++++++++++++++++++++++++----------- .flox/env/manifest.toml | 1 + 2 files changed, 210 insertions(+), 80 deletions(-) diff --git a/.flox/env/manifest.lock b/.flox/env/manifest.lock index f2238ca..6d8071d 100644 --- a/.flox/env/manifest.lock +++ b/.flox/env/manifest.lock @@ -9,6 +9,9 @@ "go": { "pkg-path": "go" }, + "imagemagick": { + "pkg-path": "imagemagick" + }, "jq": { "pkg-path": "jq" }, @@ -28,13 +31,13 @@ "description": "Tool to control the generation of non-source files from sources", "install_id": "gnumake", "license": "GPL-3.0-or-later", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "gnumake-4.4.1", "pname": "gnumake", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T02:56:52.890309Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T02:57:05.645649Z", "stabilities": [ "unstable" ], @@ -60,13 +63,13 @@ "description": "Tool to control the generation of non-source files from sources", "install_id": "gnumake", "license": "GPL-3.0-or-later", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "gnumake-4.4.1", "pname": "gnumake", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T03:12:55.696986Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:13:09.131365Z", "stabilities": [ "unstable" ], @@ -93,13 +96,13 @@ "description": "Tool to control the generation of non-source files from sources", "install_id": "gnumake", "license": "GPL-3.0-or-later", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "gnumake-4.4.1", "pname": "gnumake", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T03:23:44.936382Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:23:41.371512Z", "stabilities": [ "unstable" ], @@ -125,13 +128,13 @@ "description": "Tool to control the generation of non-source files from sources", "install_id": "gnumake", "license": "GPL-3.0-or-later", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "gnumake-4.4.1", "pname": "gnumake", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T03:33:52.985473Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:33:36.721994Z", "stabilities": [ "unstable" ], @@ -158,13 +161,13 @@ "description": "Go Programming language", "install_id": "go", "license": "BSD-3-Clause", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "go-1.25.5", "pname": "go", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T02:56:52.899062Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T02:57:05.654157Z", "stabilities": [ "unstable" ], @@ -187,13 +190,13 @@ "description": "Go Programming language", "install_id": "go", "license": "BSD-3-Clause", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "go-1.25.5", "pname": "go", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T03:12:55.711454Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:13:09.145846Z", "stabilities": [ "unstable" ], @@ -216,13 +219,13 @@ "description": "Go Programming language", "install_id": "go", "license": "BSD-3-Clause", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "go-1.25.5", "pname": "go", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T03:23:44.945010Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:23:41.380187Z", "stabilities": [ "unstable" ], @@ -245,13 +248,13 @@ "description": "Go Programming language", "install_id": "go", "license": "BSD-3-Clause", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "go-1.25.5", "pname": "go", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T03:33:53.000574Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:33:36.737943Z", "stabilities": [ "unstable" ], @@ -267,6 +270,132 @@ "group": "toplevel", "priority": 5 }, + { + "attr_path": "imagemagick", + "broken": false, + "derivation": "/nix/store/qmbnnxay37g5c78mks1mylnic01hwcqp-imagemagick-7.1.2-11.drv", + "description": "Software suite to create, edit, compose, or convert bitmap images", + "install_id": "imagemagick", + "license": "Apache-2.0", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "name": "imagemagick-7.1.2-11", + "pname": "imagemagick", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T02:57:19.735845Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "7.1.2-11", + "outputs_to_install": [ + "out" + ], + "outputs": { + "dev": "/nix/store/h62nffpg06sg3z8m1f8zylh18bf73rns-imagemagick-7.1.2-11-dev", + "doc": "/nix/store/fpnfkb98b4914f5rhi3wb4nns37s9qlg-imagemagick-7.1.2-11-doc", + "out": "/nix/store/fq59i9l5chsnhxh1ahj22df3if39vg0x-imagemagick-7.1.2-11" + }, + "system": "aarch64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "imagemagick", + "broken": false, + "derivation": "/nix/store/1rghfjnb49akjzlgmqxyvhq4zh698011-imagemagick-7.1.2-11.drv", + "description": "Software suite to create, edit, compose, or convert bitmap images", + "install_id": "imagemagick", + "license": "Apache-2.0", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "name": "imagemagick-7.1.2-11", + "pname": "imagemagick", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:13:27.370930Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "7.1.2-11", + "outputs_to_install": [ + "out", + "out" + ], + "outputs": { + "dev": "/nix/store/c3mzw08f2sywj5pcqfyvp2vg3paf0acn-imagemagick-7.1.2-11-dev", + "doc": "/nix/store/p461inbsj7x41shq5nz2c6giqsxwj4aq-imagemagick-7.1.2-11-doc", + "out": "/nix/store/jgxnhc6dd5s5vn0k0blf6csyi0vab8fd-imagemagick-7.1.2-11" + }, + "system": "aarch64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "imagemagick", + "broken": false, + "derivation": "/nix/store/2xfzrk3gh4z9xcna76llsm82szl4pdzq-imagemagick-7.1.2-11.drv", + "description": "Software suite to create, edit, compose, or convert bitmap images", + "install_id": "imagemagick", + "license": "Apache-2.0", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "name": "imagemagick-7.1.2-11", + "pname": "imagemagick", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:23:55.183314Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "7.1.2-11", + "outputs_to_install": [ + "out" + ], + "outputs": { + "dev": "/nix/store/r4a0ydcxifpifh1r9z6s4grc8pdwzgvr-imagemagick-7.1.2-11-dev", + "doc": "/nix/store/mv4avnfgxsf517llf0f4h9ag74pchga0-imagemagick-7.1.2-11-doc", + "out": "/nix/store/v00m47mxdmx2fzvxjg9147qqcvmdnybj-imagemagick-7.1.2-11" + }, + "system": "x86_64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "imagemagick", + "broken": false, + "derivation": "/nix/store/9pd78j1kx0w7srfqqji81q9ssmjhmyx8-imagemagick-7.1.2-11.drv", + "description": "Software suite to create, edit, compose, or convert bitmap images", + "install_id": "imagemagick", + "license": "Apache-2.0", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "name": "imagemagick-7.1.2-11", + "pname": "imagemagick", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:33:55.912290Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "7.1.2-11", + "outputs_to_install": [ + "out", + "out" + ], + "outputs": { + "dev": "/nix/store/47y76bq8qcnpxizqgl0c0j15s7ciix5r-imagemagick-7.1.2-11-dev", + "doc": "/nix/store/y4ncgbx0fwj3fn0j8q71fb1dk5jplzd1-imagemagick-7.1.2-11-doc", + "out": "/nix/store/dfj4g2rpz8yf1g6ybj7d37p4kmwsl772-imagemagick-7.1.2-11" + }, + "system": "x86_64-linux", + "group": "toplevel", + "priority": 5 + }, { "attr_path": "jq", "broken": false, @@ -274,13 +403,13 @@ "description": "Lightweight and flexible command-line JSON processor", "install_id": "jq", "license": "MIT", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "jq-1.8.1", "pname": "jq", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T02:57:07.357547Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T02:57:20.278663Z", "stabilities": [ "unstable" ], @@ -308,13 +437,13 @@ "description": "Lightweight and flexible command-line JSON processor", "install_id": "jq", "license": "MIT", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "jq-1.8.1", "pname": "jq", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T03:13:15.192840Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:13:28.263169Z", "stabilities": [ "unstable" ], @@ -343,13 +472,13 @@ "description": "Lightweight and flexible command-line JSON processor", "install_id": "jq", "license": "MIT", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "jq-1.8.1", "pname": "jq", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T03:23:59.361272Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:23:55.715433Z", "stabilities": [ "unstable" ], @@ -377,13 +506,13 @@ "description": "Lightweight and flexible command-line JSON processor", "install_id": "jq", "license": "MIT", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "jq-1.8.1", "pname": "jq", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T03:34:13.175749Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:33:56.962349Z", "stabilities": [ "unstable" ], @@ -412,13 +541,13 @@ "description": "Self-contained, serverless, zero-configuration, transactional SQL database engine", "install_id": "sqlite", "license": "Public Domain", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "sqlite-3.51.1", "pname": "sqlite", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T02:58:27.156134Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T02:58:44.851872Z", "stabilities": [ "unstable" ], @@ -446,13 +575,13 @@ "description": "Self-contained, serverless, zero-configuration, transactional SQL database engine", "install_id": "sqlite", "license": "Public Domain", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "sqlite-3.51.1", "pname": "sqlite", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T03:15:11.691092Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:15:17.908195Z", "stabilities": [ "unstable" ], @@ -481,13 +610,13 @@ "description": "Self-contained, serverless, zero-configuration, transactional SQL database engine", "install_id": "sqlite", "license": "Public Domain", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "sqlite-3.51.1", "pname": "sqlite", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T03:25:19.581441Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:25:22.251960Z", "stabilities": [ "unstable" ], @@ -515,13 +644,13 @@ "description": "Self-contained, serverless, zero-configuration, transactional SQL database engine", "install_id": "sqlite", "license": "Public Domain", - "locked_url": "https://github.com/flox/nixpkgs?rev=3497aa5c9457a9d88d71fa93a4a8368816fbeeba", + "locked_url": "https://github.com/flox/nixpkgs?rev=ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "name": "sqlite-3.51.1", "pname": "sqlite", - "rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", - "rev_count": 924538, - "rev_date": "2026-01-08T17:13:37Z", - "scrape_date": "2026-01-10T03:36:10.043655Z", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", + "rev_count": 925861, + "rev_date": "2026-01-11T10:35:08Z", + "scrape_date": "2026-01-12T03:36:01.474473Z", "stabilities": [ "unstable" ], diff --git a/.flox/env/manifest.toml b/.flox/env/manifest.toml index 6266f6c..8b2b402 100644 --- a/.flox/env/manifest.toml +++ b/.flox/env/manifest.toml @@ -19,6 +19,7 @@ gnumake.pkg-path = "gnumake" jq.pkg-path = "jq" go.pkg-path = "go" sqlite.pkg-path = "sqlite" +imagemagick.pkg-path = "imagemagick" # gum.pkg-path = "gum" # gum.version = "^0.14.5" -- 2.51.2 From c150000127390b0b9927523c60d028db708a7173 Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Fri, 16 Jan 2026 20:54:52 -0600 Subject: [PATCH 041/231] New quotes won't URI encode escape sequences in the DB --- internal/handler/quote.go | 5 ++- internal/handler/quote_test.go | 63 +++++++++++++++++++++++++++++ internal/templates/views/index.html | 2 +- 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 internal/handler/quote_test.go diff --git a/internal/handler/quote.go b/internal/handler/quote.go index 6c2fc4c..21a722f 100644 --- a/internal/handler/quote.go +++ b/internal/handler/quote.go @@ -2,6 +2,7 @@ package handler import ( "fmt" + "html" "net/http" ) @@ -9,8 +10,8 @@ import ( func (h *Handler) QuoteHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - quote := r.FormValue("quote") - author := r.FormValue("author") + quote := html.UnescapeString(r.FormValue("quote")) + author := html.UnescapeString(r.FormValue("author")) if quote != "" && author != "" { // Perl code did uri_unescape. net/http request parsing handles standard form encoding. diff --git a/internal/handler/quote_test.go b/internal/handler/quote_test.go new file mode 100644 index 0000000..40dab9d --- /dev/null +++ b/internal/handler/quote_test.go @@ -0,0 +1,63 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "tumble/internal/config" + "tumble/internal/data" +) + +// MockStore implements data.Store for testing +type MockStore struct { + data.Store // Embed interface to skip implementing everything + LastQuote string + LastAuthor string +} + +func (m *MockStore) InsertQuote(ctx context.Context, quote, author string) error { + m.LastQuote = quote + m.LastAuthor = author + return nil +} + +func TestQuoteHandler_UnescapesInput(t *testing.T) { + // Setup + mockStore := &MockStore{} + h := &Handler{ + Store: mockStore, + Config: &config.Config{}, + } + + // Test Case: Encoded HTML entities + // "I"ve been to fort Dicks" -> "I"ve been to fort Dicks" + form := url.Values{} + form.Add("quote", "I"ve been to fort Dicks") + form.Add("author", "james<white>") + + req := httptest.NewRequest("POST", "/quote/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + // Execute + h.QuoteHandler(w, req) + + // Verify + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Check if the store received the UNESCAPED string + expectedQuote := "I\"ve been to fort Dicks" + if mockStore.LastQuote != expectedQuote { + t.Errorf("Expected quote %q, got %q", expectedQuote, mockStore.LastQuote) + } + + expectedAuthor := "james" + if mockStore.LastAuthor != expectedAuthor { + t.Errorf("Expected author %q, got %q", expectedAuthor, mockStore.LastAuthor) + } +} diff --git a/internal/templates/views/index.html b/internal/templates/views/index.html index 1f670e4..be28878 100644 --- a/internal/templates/views/index.html +++ b/internal/templates/views/index.html @@ -162,7 +162,7 @@ return div.innerHTML; } - // Global handler for video replacement + // Global handler for video replacemen window.replaceWithVideo = function(container) { // Find URL from parent item var item = container.closest('.item'); -- 2.51.2 From 3daa67b9899b0e8510f297c2bbdb19cf4c0c15eb Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Sun, 18 Jan 2026 23:17:13 -0600 Subject: [PATCH 042/231] feat: Can now get a random quote at /quote --- internal/assets/openapi.json | 66 +++++++++++++++++++ internal/data/mysql.go | 11 ++++ internal/data/sqlite.go | 11 ++++ internal/data/store.go | 1 + internal/handler/quote.go | 35 +++++++++++ internal/handler/quote_test.go | 112 +++++++++++++++++++++++++++++++++ 6 files changed, 236 insertions(+) diff --git a/internal/assets/openapi.json b/internal/assets/openapi.json index f043aee..020fc08 100644 --- a/internal/assets/openapi.json +++ b/internal/assets/openapi.json @@ -129,6 +129,38 @@ } }, "/quote/": { + "get": { + "summary": "Get a Random Quote", + "responses": { + "200": { + "description": "Random Quote", + "content": { + "text/plain": { + "schema": { + "type": "string", + "example": "Wise words -- Author" + } + }, + "text/html": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "object", + "properties": { + "quoteID": { "type": "integer" }, + "timestamp": { "type": "string", "format": "date-time" }, + "quote": { "type": "string" }, + "author": { "type": "string" } + } + } + } + } + } + } + }, "post": { "summary": "Submit a Quote", "requestBody": { @@ -190,6 +222,40 @@ } } } + }, + "/stats": { + "get": { + "summary": "Get User Statistics", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort order (default: links)", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Statistics HTML Page", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } } } } diff --git a/internal/data/mysql.go b/internal/data/mysql.go index ec7804e..3b7d671 100644 --- a/internal/data/mysql.go +++ b/internal/data/mysql.go @@ -235,6 +235,17 @@ func (s *MySQLStore) InsertQuote(ctx context.Context, quote, author string) erro return err } +func (s *MySQLStore) GetRandomQuote(ctx context.Context) (*Quote, error) { + query := `SELECT quoteID, timestamp, quote, author FROM quote ORDER BY RAND() LIMIT 1` + row := s.db.QueryRowContext(ctx, query) + + var q Quote + if err := row.Scan(&q.ID, &q.Timestamp, &q.Quote, &q.Author); err != nil { + return nil, err + } + return &q, nil +} + func (s *MySQLStore) GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) { // Sort logic orderBy := "link_count DESC" diff --git a/internal/data/sqlite.go b/internal/data/sqlite.go index 6a01ed6..b7fd5e6 100644 --- a/internal/data/sqlite.go +++ b/internal/data/sqlite.go @@ -231,6 +231,17 @@ func (s *SQLiteStore) InsertQuote(ctx context.Context, quote, author string) err return err } +func (s *SQLiteStore) GetRandomQuote(ctx context.Context) (*Quote, error) { + query := `SELECT quoteID, timestamp, quote, author FROM quote ORDER BY RANDOM() LIMIT 1` + row := s.db.QueryRowContext(ctx, query) + + var q Quote + if err := row.Scan(&q.ID, &q.Timestamp, &q.Quote, &q.Author); err != nil { + return nil, err + } + return &q, nil +} + func (s *SQLiteStore) GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) { // Sort logic orderBy := "link_count DESC" diff --git a/internal/data/store.go b/internal/data/store.go index 67456bc..ca49f7f 100644 --- a/internal/data/store.go +++ b/internal/data/store.go @@ -58,6 +58,7 @@ type Store interface { IncrementClicks(ctx context.Context, id int) error InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) InsertQuote(ctx context.Context, quote, author string) error + GetRandomQuote(ctx context.Context) (*Quote, error) // Stats GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) diff --git a/internal/handler/quote.go b/internal/handler/quote.go index 21a722f..f118100 100644 --- a/internal/handler/quote.go +++ b/internal/handler/quote.go @@ -1,9 +1,11 @@ package handler import ( + "encoding/json" "fmt" "html" "net/http" + "strings" ) // QuoteHandler handles /quote/ submissions @@ -13,7 +15,39 @@ func (h *Handler) QuoteHandler(w http.ResponseWriter, r *http.Request) { quote := html.UnescapeString(r.FormValue("quote")) author := html.UnescapeString(r.FormValue("author")) + if quote == "" && author == "" { + // No params -> Return a random quote (fortune style) + q, err := h.Store.GetRandomQuote(ctx) + if err != nil { + http.Error(w, "Database Error", http.StatusInternalServerError) + return + } + + accept := r.Header.Get("Accept") + if strings.Contains(accept, "application/json") { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(q); err != nil { + http.Error(w, "JSON Encoding Error", http.StatusInternalServerError) + } + return + } + + responseText := fmt.Sprintf("%s -- %s", q.Quote, q.Author) + + if strings.Contains(accept, "text/html") { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // Escape for HTML safety + fmt.Fprint(w, html.EscapeString(responseText)) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprint(w, responseText) + return + } + if quote != "" && author != "" { + // Both params -> Insert Quote // Perl code did uri_unescape. net/http request parsing handles standard form encoding. // If these come in as query params or post body, FormValue gets them. @@ -28,5 +62,6 @@ func (h *Handler) QuoteHandler(w http.ResponseWriter, r *http.Request) { return } + // Partial params -> Error http.Error(w, "Missing quote or author", http.StatusBadRequest) } diff --git a/internal/handler/quote_test.go b/internal/handler/quote_test.go index 40dab9d..0e82f11 100644 --- a/internal/handler/quote_test.go +++ b/internal/handler/quote_test.go @@ -24,6 +24,13 @@ func (m *MockStore) InsertQuote(ctx context.Context, quote, author string) error return nil } +func (m *MockStore) GetRandomQuote(ctx context.Context) (*data.Quote, error) { + return &data.Quote{ + Quote: "Random wisdom", + Author: "Random Person", + }, nil +} + func TestQuoteHandler_UnescapesInput(t *testing.T) { // Setup mockStore := &MockStore{} @@ -61,3 +68,108 @@ func TestQuoteHandler_UnescapesInput(t *testing.T) { t.Errorf("Expected author %q, got %q", expectedAuthor, mockStore.LastAuthor) } } + +func TestQuoteHandler_RandomQuote(t *testing.T) { + // Setup + mockStore := &MockStore{} + h := &Handler{ + Store: mockStore, + Config: &config.Config{}, + } + + // Test Case: No params -> Random Quote + req := httptest.NewRequest("GET", "/quote/", nil) + w := httptest.NewRecorder() + + // Execute + h.QuoteHandler(w, req) + + // Verify + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + expectedBody := "Random wisdom -- Random Person" + if w.Body.String() != expectedBody { + t.Errorf("Expected body %q, got %q", expectedBody, w.Body.String()) + } +} + +func TestQuoteHandler_RandomQuote_ContentNegotiation(t *testing.T) { + // Setup + mockStore := &MockStore{} + h := &Handler{ + Store: mockStore, + Config: &config.Config{}, + } + + // Test Case: JSON + reqJSON := httptest.NewRequest("GET", "/quote/", nil) + reqJSON.Header.Set("Accept", "application/json") + wJSON := httptest.NewRecorder() + h.QuoteHandler(wJSON, reqJSON) + + if wJSON.Code != http.StatusOK { + t.Errorf("JSON: Expected status 200, got %d", wJSON.Code) + } + if contentType := wJSON.Header().Get("Content-Type"); contentType != "application/json" { + t.Errorf("JSON: Expected Content-Type application/json, got %q", contentType) + } + // Basic JSON check + if !strings.Contains(wJSON.Body.String(), `"quote":"Random wisdom"`) { + t.Errorf("JSON: Expected quote body, got %q", wJSON.Body.String()) + } + + // Test Case: HTML + reqHTML := httptest.NewRequest("GET", "/quote/", nil) + reqHTML.Header.Set("Accept", "text/html") + wHTML := httptest.NewRecorder() + h.QuoteHandler(wHTML, reqHTML) + + if wHTML.Code != http.StatusOK { + t.Errorf("HTML: Expected status 200, got %d", wHTML.Code) + } + if contentType := wHTML.Header().Get("Content-Type"); !strings.Contains(contentType, "text/html") { + t.Errorf("HTML: Expected Content-Type text/html, got %q", contentType) + } + // HTML body should be escaped if needed, but "Random wisdom" is safe. + // Let's verify string presence. + if !strings.Contains(wHTML.Body.String(), "Random wisdom -- Random Person") { + t.Errorf("HTML: Expected quote body, got %q", wHTML.Body.String()) + } +} + +func TestQuoteHandler_PartialParams(t *testing.T) { + // Setup + mockStore := &MockStore{} + h := &Handler{ + Store: mockStore, + Config: &config.Config{}, + } + + // Test Case: Only quote provided + form := url.Values{} + form.Add("quote", "Only quote") + req := httptest.NewRequest("POST", "/quote/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + h.QuoteHandler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for partial params, got %d", w.Code) + } + + // Test Case: Only author provided + form = url.Values{} + form.Add("author", "Only author") + req = httptest.NewRequest("POST", "/quote/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w = httptest.NewRecorder() + + h.QuoteHandler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for partial params, got %d", w.Code) + } +} -- 2.51.2 From b1d0ad77e4c04b22d68068eb044f9be807ae1e1c Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Sun, 18 Jan 2026 23:42:21 -0600 Subject: [PATCH 043/231] feat: if no new content, display older stuff --- internal/data/mysql.go | 36 +++++++++- internal/data/sqlite.go | 37 +++++++++- internal/data/store.go | 9 ++- internal/handler/handlers.go | 105 ++++++++++++++++++---------- internal/handler/irclink.go | 3 +- internal/templates/views/index.html | 6 ++ 6 files changed, 153 insertions(+), 43 deletions(-) diff --git a/internal/data/mysql.go b/internal/data/mysql.go index 3b7d671..14ebc21 100644 --- a/internal/data/mysql.go +++ b/internal/data/mysql.go @@ -220,7 +220,7 @@ func (s *MySQLStore) IncrementClicks(ctx context.Context, id int) error { } func (s *MySQLStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) { - query := `INSERT INTO ircLink (user, title, url, content_type) VALUES (?, ?, ?, ?)` + query := `INSERT INTO ircLink (user, title, url, content_type, clicks) VALUES (?, ?, ?, ?, 0)` res, err := s.db.ExecContext(ctx, query, user, title, url, contentType) if err != nil { return 0, err @@ -359,6 +359,40 @@ func (s *MySQLStore) GetLinksByUser(ctx context.Context, user string, limit int, return links, nil } +func (s *MySQLStore) GetGlobalTimeline(ctx context.Context, limit int, offset int) ([]TimelineItem, error) { + query := ` + SELECT + 'link' as type, ircLinkID as id, timestamp, title, url, '' as content, user as author, '' as md5sum + FROM ircLink + UNION ALL + SELECT + 'quote' as type, quoteID as id, timestamp, '' as title, '' as url, quote as content, author as author, '' as md5sum + FROM quote + UNION ALL + SELECT + 'image' as type, imageID as id, timestamp, title, url, '' as content, '' as author, md5sum + FROM image + ORDER BY timestamp DESC + LIMIT ? OFFSET ? + ` + rows, err := s.db.QueryContext(ctx, query, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []TimelineItem + for rows.Next() { + var i TimelineItem + // Scan matches SELECT order: Type, ID, Timestamp, Title, URL, Content, Author, MD5Sum + if err := rows.Scan(&i.Type, &i.ID, &i.Timestamp, &i.Title, &i.URL, &i.Content, &i.Author, &i.MD5Sum); err != nil { + return nil, err + } + items = append(items, i) + } + return items, nil +} + func (s *MySQLStore) Bootstrap(ctx context.Context) error { schema, err := SchemaFS.ReadFile("schema.mysql") if err != nil { diff --git a/internal/data/sqlite.go b/internal/data/sqlite.go index b7fd5e6..89d31bd 100644 --- a/internal/data/sqlite.go +++ b/internal/data/sqlite.go @@ -216,7 +216,7 @@ func (s *SQLiteStore) IncrementClicks(ctx context.Context, id int) error { } func (s *SQLiteStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) { - query := `INSERT INTO ircLink (user, title, url, content_type) VALUES (?, ?, ?, ?)` + query := `INSERT INTO ircLink (user, title, url, content_type, clicks) VALUES (?, ?, ?, ?, 0)` res, err := s.db.ExecContext(ctx, query, user, title, url, contentType) if err != nil { return 0, err @@ -355,6 +355,41 @@ func (s *SQLiteStore) GetLinksByUser(ctx context.Context, user string, limit int return links, nil } +func (s *SQLiteStore) GetGlobalTimeline(ctx context.Context, limit int, offset int) ([]TimelineItem, error) { + // SQLite queries for literal strings sometimes need quotes or casting. + query := ` + SELECT + 'link' as type, ircLinkID as id, timestamp, title, url, '' as content, user as author, '' as md5sum + FROM ircLink + UNION ALL + SELECT + 'quote' as type, quoteID as id, timestamp, '' as title, '' as url, quote as content, author as author, '' as md5sum + FROM quote + UNION ALL + SELECT + 'image' as type, imageID as id, timestamp, title, url, '' as content, '' as author, md5sum + FROM image + ORDER BY timestamp DESC + LIMIT ? OFFSET ? + ` + rows, err := s.db.QueryContext(ctx, query, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []TimelineItem + for rows.Next() { + var i TimelineItem + // Scan matches SELECT order: Type, ID, Timestamp, Title, URL, Content, Author, MD5Sum + if err := rows.Scan(&i.Type, &i.ID, &i.Timestamp, &i.Title, &i.URL, &i.Content, &i.Author, &i.MD5Sum); err != nil { + return nil, err + } + items = append(items, i) + } + return items, nil +} + func (s *SQLiteStore) Bootstrap(ctx context.Context) error { schema, err := SchemaFS.ReadFile("schema.sqlite") if err != nil { diff --git a/internal/data/store.go b/internal/data/store.go index ca49f7f..8b3636f 100644 --- a/internal/data/store.go +++ b/internal/data/store.go @@ -38,12 +38,14 @@ type UserStat struct { } type TimelineItem struct { - Type string `json:"type"` // "link" or "quote" + Type string `json:"type"` // "link", "quote", or "image" ID int `json:"id"` Timestamp time.Time `json:"timestamp"` - Title string `json:"title"` // For links - URL string `json:"url"` // For links + Title string `json:"title"` // For links and images + URL string `json:"url"` // For links and images Content string `json:"content"` // For quotes + Author string `json:"author"` // For quotes (and links/images as User) + MD5Sum string `json:"md5sum"` // For images } type Store interface { @@ -64,6 +66,7 @@ type Store interface { GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) GetLinksByUser(ctx context.Context, user string, limit int, offset int) ([]IRCLink, error) GetUserTimeline(ctx context.Context, user string, filterType string, limit int, offset int) ([]TimelineItem, error) + GetGlobalTimeline(ctx context.Context, limit int, offset int) ([]TimelineItem, error) Bootstrap(ctx context.Context) error diff --git a/internal/handler/handlers.go b/internal/handler/handlers.go index 9852c41..457a55c 100644 --- a/internal/handler/handlers.go +++ b/internal/handler/handlers.go @@ -41,9 +41,10 @@ type IndexPageData struct { GitCommit string // Placeholder GitCommitURL string // Placeholder // For XML - BaseURL template.HTML - Poster string - FilterType string + BaseURL template.HTML + Poster string + FilterType string + IsFallbackContent bool } func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { @@ -83,6 +84,7 @@ func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { poster := params.Get("poster") filterType := params.Get("type") // "links", "quotes", or empty/all + isFallback := false if poster != "" { // Filtered View: Only links/quotes by 'poster' @@ -103,15 +105,6 @@ func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { User: poster, Title: item.Title, URL: item.URL, - // Clicks/ContentType are skipped/nulled here as the query didn't select them or we don't display them in timeline similarly - // Wait, current templates MIGHT need content_type for icon? - // My GetUserTimeline SELECT didn't include clicks or content_type for complexity. - // Let's check IRCLink struct usage. content_type used for icon. - // If I need it, I should update the SELECT. - // For now, let's assume empty defaulting is acceptable or update query if needed. - // Actually, better to fetch them if possible. - // But the union makes it tricky if columns differ. - // Let's stick to basics. }) } else if item.Type == "quote" { quotes = append(quotes, data.Quote{ @@ -138,14 +131,57 @@ func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { return } - // Combine and Sort - // Since we fetched by specific intervals, we just need to merge and sort by timestamp. - // OR we can process them all and then sort. - // But simply rendering them in memory and then concatenating is easier if we process them in time order. - // Perl simply assumes they are ordered by key timestamp in the hash? - // Actually Perl: `foreach my $item_id ( reverse sort { $a cmp $b } keys %{$data} )` - // The keys are timestamps (Wait, `key => 'timestamp'` in fetch means keys are timestamps). - // So items are sorted by timestamp. + // Check for empty state on front page (standard view, page 1) + if poster == "" && i == 1 && len(ircLinks) == 0 && len(images) == 0 && len(quotes) == 0 { + slog.Info("No recent content found, fetching global timeline fallback") + fallbackItems, err := h.Store.GetGlobalTimeline(ctx, 20, 0) + if err == nil { + isFallback = true + for _, item := range fallbackItems { + switch item.Type { + case "link": + ircLinks = append(ircLinks, data.IRCLink{ + ID: item.ID, + Timestamp: item.Timestamp, + User: item.Author, + Title: item.Title, + URL: item.URL, + }) + case "quote": + quotes = append(quotes, data.Quote{ + ID: item.ID, + Timestamp: item.Timestamp, + Author: item.Author, + Quote: item.Content, + }) + case "image": + images = append(images, data.Image{ + ID: item.ID, + Timestamp: item.Timestamp, + Title: item.Title, + Link: item.URL, // In GetGlobalTimeline, we mapped URL to URL, but Image struct has Link and URL. + // Looking at mysql select: 'image' as type... url ... + // In Image struct: Link is usually the click-through, URL is the src. + // Let's re-verify image struct usage. + // Image struct: Link string `json:"link"`, URL string `json:"url"` + // In GetRecentImages: Scan(&i.Link, &i.URL...) + // In GetGlobalTimeline: SELECT ... url ... + // We might be missing the 'link' field in global timeline for images if we just select one 'url' column. + // TimelineItem has 'URL'. + // For now, let's map URL to URL and assume Link is same or empty? + // Revisiting GetGlobalTimeline query: + // SELECT 'image', ..., url, ... + // It seems we only selected URL. We might want to fix GetGlobalTimeline to include Link if essential. + // Assuming URL is the main thing for display. + URL: item.URL, + MD5Sum: item.MD5Sum, + }) + } + } + } else { + slog.Error("Error fetching global timeline fallback", "error", err) + } + } type ProcessedItem struct { Timestamp string // for sorting @@ -228,8 +264,6 @@ func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { } // Sort items (descending) - // Simple bubble sort or whatever for small lists, or sort packages. - // For "100% compatibility" I must sort descending. for j := 0; j < len(processedItems); j++ { for k := j + 1; k < len(processedItems); k++ { if processedItems[j].Timestamp < processedItems[k].Timestamp { @@ -269,15 +303,11 @@ func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { topLinks, err := h.Store.GetTopIRCLinks(ctx, 12, 6, 5) if err == nil { for _, l := range topLinks { - // Link content: Title if len(l.Title) > 30 { l.Title = l.Title[:30] + "..." } - // Link content: Title content := fmt.Sprintf(`%s`, h.Config.BaseURL, l.ID, l.Title) - // Render item - // Using map for flexibility data := map[string]interface{}{ "Content": template.HTML(content), } @@ -305,7 +335,7 @@ func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { navP = fmt.Sprintf(``, posterParam) } if i == 1 { - navN = "" // Perl: $nav->{'n'} = '' unless $self->{'arg'}->{'i'}; + navN = "" } // View Data @@ -315,16 +345,17 @@ func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { } viewData := IndexPageData{ - PageTitle: pageTitle, - Container: template.HTML(containerHTML), - Hot: template.HTML(hotHTML), - NavP: template.HTML(navP), - NavN: template.HTML(navN), - BaseURL: template.HTML(h.Config.BaseURL), - Poster: poster, - FilterType: filterType, - GitCommit: version.CommitHash, - GitCommitURL: fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash), + PageTitle: pageTitle, + Container: template.HTML(containerHTML), + Hot: template.HTML(hotHTML), + NavP: template.HTML(navP), + NavN: template.HTML(navN), + BaseURL: template.HTML(h.Config.BaseURL), + Poster: poster, + FilterType: filterType, + GitCommit: version.CommitHash, + GitCommitURL: fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash), + IsFallbackContent: isFallback, } templateName := "index.html" diff --git a/internal/handler/irclink.go b/internal/handler/irclink.go index d0d6e5a..b81fd10 100644 --- a/internal/handler/irclink.go +++ b/internal/handler/irclink.go @@ -47,7 +47,8 @@ func (h *Handler) IRCLinkHandler(w http.ResponseWriter, r *http.Request) { // Insert id, err := h.Store.InsertIRCLink(ctx, user, title, url, contentType) if err != nil { - http.Error(w, "Database Error", http.StatusInternalServerError) + log.Printf("InsertIRCLink error: %v", err) + http.Error(w, fmt.Sprintf("Database Error: %v", err), http.StatusInternalServerError) return } diff --git a/internal/templates/views/index.html b/internal/templates/views/index.html index be28878..b9a6bf8 100644 --- a/internal/templates/views/index.html +++ b/internal/templates/views/index.html @@ -258,6 +258,12 @@ Quotes
{{end}} + {{if .IsFallbackContent}} +
+
It's been a bit quiet lately...
+
Here is some content from the archives. Why not share something new?
+
+ {{end}} {{.Container}} -- 2.51.2 From 7daa2545f985722968c9b108db6cdc644edbdade Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Sun, 18 Jan 2026 23:42:35 -0600 Subject: [PATCH 044/231] chore: Add gemini specific rules --- .gemini/GEMINI.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 .gemini/GEMINI.md diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md new file mode 120000 index 0000000..6f16891 --- /dev/null +++ b/.gemini/GEMINI.md @@ -0,0 +1 @@ +../.cursorrules \ No newline at end of file -- 2.51.2 From 1743c25a48972bab455c2e48453c11b244fa75f2 Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Sun, 18 Jan 2026 23:52:54 -0600 Subject: [PATCH 045/231] fix: Improve green color to not hurt my eyes --- internal/assets/css/screen.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/assets/css/screen.css b/internal/assets/css/screen.css index 84474eb..e57892b 100644 --- a/internal/assets/css/screen.css +++ b/internal/assets/css/screen.css @@ -17,7 +17,7 @@ /* Content Elements */ --img-placeholder: #bbb; --author-color: #aaa; - --link-color: #6c3; + --link-color: #2c6e2f; --quote-color: #d5b; --header-color: #aaa; @@ -63,7 +63,7 @@ /* Content Elements */ --img-placeholder: #333; --author-color: #888; - --link-color: #8e5; + --link-color: #66bb6a; --quote-color: #e6c; --header-color: #888; -- 2.51.2 From b1f9907568990566491120fb4dfde04ecf7ece55 Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Mon, 19 Jan 2026 01:13:42 -0600 Subject: [PATCH 046/231] fix: Youtube rendering and colors --- internal/assets/css/screen.css | 94 ++++++++++++++++++++++++++++- internal/handler/handlers.go | 6 +- internal/handler/preview.go | 2 + internal/templates/views/index.html | 24 ++------ 4 files changed, 101 insertions(+), 25 deletions(-) diff --git a/internal/assets/css/screen.css b/internal/assets/css/screen.css index e57892b..6768453 100644 --- a/internal/assets/css/screen.css +++ b/internal/assets/css/screen.css @@ -18,7 +18,7 @@ --img-placeholder: #bbb; --author-color: #aaa; --link-color: #2c6e2f; - --quote-color: #d5b; + --quote-color: #d65a00; --header-color: #aaa; /* OG Preview (Cards) */ @@ -64,7 +64,7 @@ --img-placeholder: #333; --author-color: #888; --link-color: #66bb6a; - --quote-color: #e6c; + --quote-color: #ffb74d; --header-color: #888; /* OG Preview (Cards) */ @@ -377,6 +377,7 @@ body { position: relative; /* Match OG Card properties */ max-width: 600px; + width: 100%; margin-top: 8px; background: #000; @@ -425,6 +426,16 @@ body { padding-bottom: 20px; } +#navigation a { + color: var(--text-main); + text-decoration: none; + transition: color 0.2s; +} + +#navigation a:hover { + color: var(--link-color); +} + /* Slack-like Link Preview Styles */ .og-card { margin-top: 8px; @@ -483,7 +494,7 @@ body { .og-image { position: relative; /* For absolute positioning of play button */ - display: inline-block; + display: block; } .og-image.is-video { @@ -553,3 +564,80 @@ body { text-decoration: underline; color: #666; } + +/* Material Symbols Sizing */ +.material-symbols-rounded { + font-variation-settings: + 'FILL' 0, + 'wght' 400, + 'GRAD' 0, + 'opsz' 24; + font-size: 20px; /* Default matches previous SVG size */ +} + +/* Mobile Responsiveness */ +@media (max-width: 768px) { + #page { + display: block; /* Stack everything */ + padding: 15px; /* Reduce padding */ + width: 100%; + max-width: 100%; + } + + #masthead { + font-size: 48px; /* Smaller title */ + line-height: 1.1; + flex-direction: column; + align-items: flex-start; + } + + #theme-toggle { + position: absolute; + top: 0; + right: 0; + margin-top: 0; + } + + #sidebar { + margin-bottom: 40px; + } + + /* Date Widget Reset for Mobile */ + .date-icon { + margin-top: 0; + margin-left: 0; + float: none; + width: 100%; + text-align: left; + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 10px; + } + + .date-meta { + position: static; + transform: none; + display: inline-block; + width: auto; + } + + .date-date { + display: inline-block; + font-size: 24px; /* Smaller date number */ + } + + .date-day, .date-month, .date-year { + display: inline-block; + font-size: 14px; + margin-left: 5px; + } + + .quote { + padding-left: 15px; /* Reduce quote padding */ + } + + .youtube-embed-wrapper { + max-width: 100%; + } +} diff --git a/internal/handler/handlers.go b/internal/handler/handlers.go index 457a55c..4e85eb5 100644 --- a/internal/handler/handlers.go +++ b/internal/handler/handlers.go @@ -329,10 +329,10 @@ func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { } if iParam != "" || i > 1 { - navP = fmt.Sprintf(``, i+1, posterParam) - navN = fmt.Sprintf(`  `, i-1, posterParam) + navP = fmt.Sprintf(`chevron_left`, i+1, posterParam) + navN = fmt.Sprintf(`  chevron_right`, i-1, posterParam) } else { - navP = fmt.Sprintf(``, posterParam) + navP = fmt.Sprintf(`chevron_left`, posterParam) } if i == 1 { navN = "" diff --git a/internal/handler/preview.go b/internal/handler/preview.go index ef5d6cf..037fd34 100644 --- a/internal/handler/preview.go +++ b/internal/handler/preview.go @@ -154,6 +154,8 @@ func (h *Handler) OGPreviewHandler(w http.ResponseWriter, r *http.Request) { }) return } + // Force type to video for YouTube + metadata["type"] = "video" } json.NewEncoder(w).Encode(metadata) diff --git a/internal/templates/views/index.html b/internal/templates/views/index.html index b9a6bf8..9b99c39 100644 --- a/internal/templates/views/index.html +++ b/internal/templates/views/index.html @@ -5,9 +5,9 @@ + - - -`, version.CommitHash, version.CommitHash) + data := map[string]string{ + "GitCommit": version.CommitHash, + "GitCommitURL": fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash), + } w.Header().Set("Content-Type", "text/html") - w.Write([]byte(html)) + if err := h.Renderer.Render(w, "docs.html", data); err != nil { + slog.Error("Error rendering docs", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } } diff --git a/internal/templates/views/docs.html b/internal/templates/views/docs.html new file mode 100644 index 0000000..1647305 --- /dev/null +++ b/internal/templates/views/docs.html @@ -0,0 +1,133 @@ + + + + + + Tumble API Docs + + + + + + + + + +
+ +{{template "footer" .}} + + + + + diff --git a/internal/templates/views/footer.html b/internal/templates/views/footer.html new file mode 100644 index 0000000..59730e9 --- /dev/null +++ b/internal/templates/views/footer.html @@ -0,0 +1,23 @@ +{{define "footer"}} + +{{end}} diff --git a/internal/templates/views/index.html b/internal/templates/views/index.html index 67da565..00981f5 100644 --- a/internal/templates/views/index.html +++ b/internal/templates/views/index.html @@ -334,25 +334,7 @@ - + {{template "footer" .}} +{{end}} -- 2.51.2 From 4406dddc51b39b943d38ddf369f04c4791a401b9 Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Thu, 22 Jan 2026 18:47:06 -0600 Subject: [PATCH 065/231] feat: Add links to API docs and Stats on sidebar --- internal/templates/views/docs.html | 84 ++++++++--------------------- internal/templates/views/index.html | 6 +++ internal/templates/views/stats.html | 54 ++++++++----------- 3 files changed, 50 insertions(+), 94 deletions(-) diff --git a/internal/templates/views/docs.html b/internal/templates/views/docs.html index cbe1a72..6be8bcb 100644 --- a/internal/templates/views/docs.html +++ b/internal/templates/views/docs.html @@ -19,34 +19,28 @@ :root { --bg-color: #ffffff; --text-color: #333333; - --header-bg: #f8f8f8; - --border-color: #ddd; - - /* Footer Variables from screen.css */ - --footer-bg: #f5f5f5; - --footer-border: #eee; - --footer-text: #666; - --footer-link: #444; - --icon-fill: #000000; - } - [data-theme="dark"] { - --bg-color: #0d1117; - --text-color: #c9d1d9; - --header-bg: #161b22; - --border-color: #30363d; + /* Footer Variables from screen.css */ + --footer-bg: #f5f5f5; + --footer-border: #eee; + --footer-text: #666; + --footer-link: #444; + --icon-fill: #000000; + } + [data-theme="dark"] { + --bg-color: #0d1117; + --text-color: #c9d1d9; - /* Footer Variables from screen.css */ - --footer-bg: #1a1a1a; - --footer-border: #333; - --footer-text: #888; - --footer-link: #aaa; - --icon-fill: #ffffff; - } - body { margin: 0; padding: 0 0 60px 0; display: flex; flex-direction: column; min-height: 100vh; font-family: sans-serif; background-color: var(--bg-color); color: var(--text-color); } - #swagger-ui { flex: 1; } - .nav-header { padding: 10px 20px; background: var(--header-bg); border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; } - .nav-header a { text-decoration: none; color: var(--text-color); font-weight: bold; } - .nav-header a:hover { color: var(--text-color); opacity: 0.8; } + /* Footer Variables from screen.css */ + --footer-bg: #1a1a1a; + --footer-border: #333; + --footer-text: #888; + --footer-link: #aaa; + --icon-fill: #ffffff; + } + body { margin: 0; padding: 0 0 60px 0; display: flex; flex-direction: column; min-height: 100vh; font-family: sans-serif; background-color: var(--bg-color); color: var(--text-color); } + #swagger-ui { flex: 1; } + + /* Footer Styles */ /* Footer Styles */ #footer { @@ -75,20 +69,7 @@ } - #theme-toggle { - background: none; - border: none; - cursor: pointer; - color: var(--text-color); - padding: 5px; - display: flex; - align-items: center; - justify-content: center; - } - #theme-toggle:hover { - background-color: rgba(128, 128, 128, 0.1); - border-radius: 50%; - } + /* Dark Mode for Swagger UI */ [data-theme="dark"] #swagger-ui { @@ -98,12 +79,7 @@ - +{{template "header" .}}
@@ -118,21 +94,7 @@ }); }; - // Theme Toggle - (function () { - var toggle = document.getElementById("theme-toggle"); - var html = document.documentElement; - toggle.addEventListener("click", function () { - if (html.getAttribute("data-theme") === "dark") { - html.removeAttribute("data-theme"); - localStorage.setItem("theme", "light"); - } else { - html.setAttribute("data-theme", "dark"); - localStorage.setItem("theme", "dark"); - } - }); - })(); diff --git a/internal/templates/views/index.html b/internal/templates/views/index.html index 00981f5..36bfc4c 100644 --- a/internal/templates/views/index.html +++ b/internal/templates/views/index.html @@ -310,6 +310,12 @@ +
+ +
+
+ +
diff --git a/internal/templates/views/stats.html b/internal/templates/views/stats.html index b4a4913..cdd99a0 100644 --- a/internal/templates/views/stats.html +++ b/internal/templates/views/stats.html @@ -2,8 +2,14 @@ tumblefish.stats + diff --git a/internal/templates/views/index.html b/internal/templates/views/index.html index 36bfc4c..4626b70 100644 --- a/internal/templates/views/index.html +++ b/internal/templates/views/index.html @@ -283,40 +283,13 @@ +{{template "header" .}}
tumblefish. -
@@ -342,22 +315,6 @@ {{template "footer" .}} - - + diff --git a/internal/templates/views/tumble_buttons.html b/internal/templates/views/tumble_buttons.html index 52a1e73..cfb1f66 100644 --- a/internal/templates/views/tumble_buttons.html +++ b/internal/templates/views/tumble_buttons.html @@ -13,46 +13,94 @@ var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s); })(); + + + + + +{{template "header" .}}
- tumblefish. + tumblefish.
- + {{if .User}}
-
!!
-
buttons
-
yay!
-
-
-
- So how do I install this crap?? -
-
- -
+ {{else}} +
+
+
!!
+
buttons
+
yay!
+
+
+
+ WTF is a tumblebutton?!? +
+
+ +
+ {{end}}
+{{template "footer" .}} -- 2.51.2 From 74b2c75f6a83cf2f503f76f0849d9dccf72faadf Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Fri, 23 Jan 2026 00:15:19 -0600 Subject: [PATCH 069/231] fix: Rss feed was broken, now fixed Even has a test --- .flox/env/manifest.lock | 329 ++++++++++++------ .flox/env/manifest.toml | 1 + .../templates/views/tumble_item_image.xml | 2 +- .../templates/views/tumble_item_ircLink.xml | 2 +- tests/api_test.sh | 17 + 5 files changed, 249 insertions(+), 102 deletions(-) diff --git a/.flox/env/manifest.lock b/.flox/env/manifest.lock index 4a44962..b01081b 100644 --- a/.flox/env/manifest.lock +++ b/.flox/env/manifest.lock @@ -15,6 +15,9 @@ "jq": { "pkg-path": "jq" }, + "libxml2": { + "pkg-path": "libxml2" + }, "sqlite": { "pkg-path": "sqlite" } @@ -31,13 +34,13 @@ "description": "Tool to control the generation of non-source files from sources", "install_id": "gnumake", "license": "GPL-3.0-or-later", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "gnumake-4.4.1", "pname": "gnumake", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T03:20:16.399150Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T04:27:54.825501Z", "stabilities": [ "unstable" ], @@ -63,13 +66,13 @@ "description": "Tool to control the generation of non-source files from sources", "install_id": "gnumake", "license": "GPL-3.0-or-later", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "gnumake-4.4.1", "pname": "gnumake", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T03:59:50.426816Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T05:02:21.156754Z", "stabilities": [ "unstable" ], @@ -96,13 +99,13 @@ "description": "Tool to control the generation of non-source files from sources", "install_id": "gnumake", "license": "GPL-3.0-or-later", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "gnumake-4.4.1", "pname": "gnumake", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T04:36:59.952666Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T05:34:06.667013Z", "stabilities": [ "unstable" ], @@ -128,13 +131,13 @@ "description": "Tool to control the generation of non-source files from sources", "install_id": "gnumake", "license": "GPL-3.0-or-later", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "gnumake-4.4.1", "pname": "gnumake", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T05:18:19.770679Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T06:20:21.629365Z", "stabilities": [ "unstable" ], @@ -161,13 +164,13 @@ "description": "Go Programming language", "install_id": "go", "license": "BSD-3-Clause", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "go-1.25.5", "pname": "go", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T03:20:16.408031Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T04:27:54.834801Z", "stabilities": [ "unstable" ], @@ -190,13 +193,13 @@ "description": "Go Programming language", "install_id": "go", "license": "BSD-3-Clause", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "go-1.25.5", "pname": "go", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T03:59:50.441213Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T05:02:21.171130Z", "stabilities": [ "unstable" ], @@ -219,13 +222,13 @@ "description": "Go Programming language", "install_id": "go", "license": "BSD-3-Clause", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "go-1.25.5", "pname": "go", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T04:36:59.961434Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T05:34:06.675736Z", "stabilities": [ "unstable" ], @@ -248,13 +251,13 @@ "description": "Go Programming language", "install_id": "go", "license": "BSD-3-Clause", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "go-1.25.5", "pname": "go", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T05:18:19.785721Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T06:20:21.644404Z", "stabilities": [ "unstable" ], @@ -277,13 +280,13 @@ "description": "Software suite to create, edit, compose, or convert bitmap images", "install_id": "imagemagick", "license": "Apache-2.0", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "imagemagick-7.1.2-11", "pname": "imagemagick", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T03:20:30.768599Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T04:28:09.158015Z", "stabilities": [ "unstable" ], @@ -308,13 +311,13 @@ "description": "Software suite to create, edit, compose, or convert bitmap images", "install_id": "imagemagick", "license": "Apache-2.0", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "imagemagick-7.1.2-11", "pname": "imagemagick", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T04:00:10.158509Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T05:02:38.341105Z", "stabilities": [ "unstable" ], @@ -340,13 +343,13 @@ "description": "Software suite to create, edit, compose, or convert bitmap images", "install_id": "imagemagick", "license": "Apache-2.0", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "imagemagick-7.1.2-11", "pname": "imagemagick", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T04:37:14.500447Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T05:34:20.743720Z", "stabilities": [ "unstable" ], @@ -371,13 +374,13 @@ "description": "Software suite to create, edit, compose, or convert bitmap images", "install_id": "imagemagick", "license": "Apache-2.0", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "imagemagick-7.1.2-11", "pname": "imagemagick", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T05:18:38.772073Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T06:20:40.171110Z", "stabilities": [ "unstable" ], @@ -403,13 +406,13 @@ "description": "Lightweight and flexible command-line JSON processor", "install_id": "jq", "license": "MIT", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "jq-1.8.1", "pname": "jq", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T03:20:31.314975Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T04:28:09.719633Z", "stabilities": [ "unstable" ], @@ -437,13 +440,13 @@ "description": "Lightweight and flexible command-line JSON processor", "install_id": "jq", "license": "MIT", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "jq-1.8.1", "pname": "jq", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T04:00:11.205017Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T05:02:39.216741Z", "stabilities": [ "unstable" ], @@ -472,13 +475,13 @@ "description": "Lightweight and flexible command-line JSON processor", "install_id": "jq", "license": "MIT", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "jq-1.8.1", "pname": "jq", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T04:37:15.055061Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T05:34:21.285253Z", "stabilities": [ "unstable" ], @@ -506,13 +509,13 @@ "description": "Lightweight and flexible command-line JSON processor", "install_id": "jq", "license": "MIT", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "jq-1.8.1", "pname": "jq", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T05:18:39.770550Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T06:20:41.197450Z", "stabilities": [ "unstable" ], @@ -534,6 +537,132 @@ "group": "toplevel", "priority": 5 }, + { + "attr_path": "libxml2", + "broken": false, + "derivation": "/nix/store/j4kv9crjgn56l7hxszbs6bbibymp63qf-libxml2-2.15.1.drv", + "description": "XML parsing library for C", + "install_id": "libxml2", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "name": "libxml2-2.15.1", + "pname": "libxml2", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T04:28:11.980742Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "2.15.1", + "outputs_to_install": [ + "bin" + ], + "outputs": { + "bin": "/nix/store/4qfb3xm18gfgwxwz640n3665q48qrl51-libxml2-2.15.1-bin", + "dev": "/nix/store/9pna2kdj9358d9k9z02vidlbl25ra6hw-libxml2-2.15.1-dev", + "out": "/nix/store/48r5zq08j370snpb43x83il9pikmxn02-libxml2-2.15.1" + }, + "system": "aarch64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "libxml2", + "broken": false, + "derivation": "/nix/store/xv1k7m3rmc26iy1par91bdmg2cwrhxbh-libxml2-2.15.1.drv", + "description": "XML parsing library for C", + "install_id": "libxml2", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "name": "libxml2-2.15.1", + "pname": "libxml2", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T05:02:43.190080Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "2.15.1", + "outputs_to_install": [ + "bin", + "bin" + ], + "outputs": { + "bin": "/nix/store/ckm86lqsa8knk6gkvkibxh9lawdixzf4-libxml2-2.15.1-bin", + "dev": "/nix/store/p17klc3qq4nl9ihfwqfpi8cnqvcf92xz-libxml2-2.15.1-dev", + "out": "/nix/store/wfk1mrpj1r5f7iyihjnh0jjixr900z7y-libxml2-2.15.1" + }, + "system": "aarch64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "libxml2", + "broken": false, + "derivation": "/nix/store/91f3jqbbk7x5qdbcnxwrkb5z2s9rwijc-libxml2-2.15.1.drv", + "description": "XML parsing library for C", + "install_id": "libxml2", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "name": "libxml2-2.15.1", + "pname": "libxml2", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T05:34:23.453777Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "2.15.1", + "outputs_to_install": [ + "bin" + ], + "outputs": { + "bin": "/nix/store/a0gpzvavak3x4wz1v7f4dk2mixckajpv-libxml2-2.15.1-bin", + "dev": "/nix/store/f9z95b0blsfd6ws0jnx99j7ijrghzpfc-libxml2-2.15.1-dev", + "out": "/nix/store/kwj8y03m3s5qsw3105lh3r679drrk7j6-libxml2-2.15.1" + }, + "system": "x86_64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "libxml2", + "broken": false, + "derivation": "/nix/store/rp4jlfwhr2ni4v2bpr5kr8ss77r8mq7j-libxml2-2.15.1.drv", + "description": "XML parsing library for C", + "install_id": "libxml2", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "name": "libxml2-2.15.1", + "pname": "libxml2", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T06:20:45.643871Z", + "stabilities": [ + "unstable" + ], + "unfree": false, + "version": "2.15.1", + "outputs_to_install": [ + "bin", + "bin" + ], + "outputs": { + "bin": "/nix/store/mvfalwl76ms34wxrh9jsw6g46fsl9nbh-libxml2-2.15.1-bin", + "dev": "/nix/store/5nl2zr46dic7mxcw6cvbq85wa8c2gngh-libxml2-2.15.1-dev", + "out": "/nix/store/y68hvcdj0j31x7hy9qnijswqykp21wvz-libxml2-2.15.1" + }, + "system": "x86_64-linux", + "group": "toplevel", + "priority": 5 + }, { "attr_path": "sqlite", "broken": false, @@ -541,13 +670,13 @@ "description": "Self-contained, serverless, zero-configuration, transactional SQL database engine", "install_id": "sqlite", "license": "Public Domain", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "sqlite-3.51.1", "pname": "sqlite", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T03:22:04.066815Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T04:29:42.519920Z", "stabilities": [ "unstable" ], @@ -575,13 +704,13 @@ "description": "Self-contained, serverless, zero-configuration, transactional SQL database engine", "install_id": "sqlite", "license": "Public Domain", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "sqlite-3.51.1", "pname": "sqlite", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T04:02:10.617033Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T05:04:34.424207Z", "stabilities": [ "unstable" ], @@ -610,13 +739,13 @@ "description": "Self-contained, serverless, zero-configuration, transactional SQL database engine", "install_id": "sqlite", "license": "Public Domain", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "sqlite-3.51.1", "pname": "sqlite", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T04:38:43.056576Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T05:35:47.630880Z", "stabilities": [ "unstable" ], @@ -644,13 +773,13 @@ "description": "Self-contained, serverless, zero-configuration, transactional SQL database engine", "install_id": "sqlite", "license": "Public Domain", - "locked_url": "https://github.com/flox/nixpkgs?rev=e4bae1bd10c9c57b2cf517953ab70060a828ee6f", + "locked_url": "https://github.com/flox/nixpkgs?rev=80e4adbcf8992d3fd27ad4964fbb84907f9478b0", "name": "sqlite-3.51.1", "pname": "sqlite", - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", - "rev_count": 928726, - "rev_date": "2026-01-16T12:01:49Z", - "scrape_date": "2026-01-17T05:20:43.881042Z", + "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev_count": 930839, + "rev_date": "2026-01-20T05:17:20Z", + "scrape_date": "2026-01-22T06:22:51.878481Z", "stabilities": [ "unstable" ], diff --git a/.flox/env/manifest.toml b/.flox/env/manifest.toml index 8b2b402..ea1261b 100644 --- a/.flox/env/manifest.toml +++ b/.flox/env/manifest.toml @@ -20,6 +20,7 @@ jq.pkg-path = "jq" go.pkg-path = "go" sqlite.pkg-path = "sqlite" imagemagick.pkg-path = "imagemagick" +libxml2.pkg-path = "libxml2" # gum.pkg-path = "gum" # gum.version = "^0.14.5" diff --git a/internal/templates/views/tumble_item_image.xml b/internal/templates/views/tumble_item_image.xml index 5c80214..0d6b32f 100644 --- a/internal/templates/views/tumble_item_image.xml +++ b/internal/templates/views/tumble_item_image.xml @@ -2,6 +2,6 @@ {{.Title}} {{.URL}} tumble-image-{{.ID}} - {{.Content}} + {{.Timestamp}} diff --git a/internal/templates/views/tumble_item_ircLink.xml b/internal/templates/views/tumble_item_ircLink.xml index 4b56d1f..fea14b7 100644 --- a/internal/templates/views/tumble_item_ircLink.xml +++ b/internal/templates/views/tumble_item_ircLink.xml @@ -2,6 +2,6 @@ {{.Title}} http://{{.BaseURL}}/irclink/?{{.ID}} tumble-{{.ID}} - {{.Content}} + {{.Timestamp}} diff --git a/tests/api_test.sh b/tests/api_test.sh index 36169ab..d976d79 100755 --- a/tests/api_test.sh +++ b/tests/api_test.sh @@ -41,6 +41,23 @@ check_content_type "/" "text/html" check_200 "/index.xml?dtype=rss" check_content_type "/index.xml?dtype=rss" "text/xml" +# Optional: XML Validation if xmllint is present +if command -v xmllint &> /dev/null; then + echo -n "Validating RSS XML structure... " + curl -s "$BASE_URL/index.xml?dtype=rss" > rss_temp.xml + if xmllint --noout rss_temp.xml 2>/dev/null; then + echo "OK" + rm rss_temp.xml + else + echo "FAIL (XML Validation errors)" + xmllint --noout rss_temp.xml + rm rss_temp.xml + FAIL=1 + fi +else + echo "Skipping XML validation (xmllint not found)" +fi + # 3. Search (HTML) check_200 "/search.cgi?search=test" -- 2.51.2 From 966c7e1782c8407f9f55f2b05886fc265fe46924 Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Fri, 23 Jan 2026 12:49:54 -0600 Subject: [PATCH 070/231] fix: Twitter card rendering when a 404 occurs --- internal/handler/preview.go | 22 ++++++++++++++++++++-- internal/service/content.go | 4 +++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/internal/handler/preview.go b/internal/handler/preview.go index fe793c3..4c1cf24 100644 --- a/internal/handler/preview.go +++ b/internal/handler/preview.go @@ -75,6 +75,18 @@ func (h *Handler) OGPreviewHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(meta) return } + // If OEmbed returned ANY error (404, 403, etc.), we trust it. Do not fall back to scrape. + if strings.Contains(err.Error(), "oembed status") { + var code int + if n, _ := fmt.Sscanf(err.Error(), "oembed status %d", &code); n != 1 { + code = 404 + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Tweet Unavailable", + "status": code, + }) + return + } } else if strings.Contains(urlParam, "youtube.com") || strings.Contains(urlParam, "youtu.be") { meta, err := h.GetYouTubePreview(urlParam) if err == nil { @@ -82,7 +94,7 @@ func (h *Handler) OGPreviewHandler(w http.ResponseWriter, r *http.Request) { return } // If we detected a soft 404, stop here and return 404 so UI can render "missing" badge - if err != nil && err.Error() == "status 404" { + if err.Error() == "status 404" { json.NewEncoder(w).Encode(map[string]interface{}{ "error": "Video Unavailable", "status": 404, @@ -197,7 +209,13 @@ func (h *Handler) fetchOGScrape(w http.ResponseWriter, urlParam string) { json.NewEncoder(w).Encode(map[string]interface{}{ "error": "HTTP Error", // Extract status code if possible, or default to 400 - "status": 400, + "status": func() int { + var code int + if n, _ := fmt.Sscanf(err.Error(), "status %d", &code); n == 1 { + return code + } + return 400 + }(), }) } else { json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch metadata"}) diff --git a/internal/service/content.go b/internal/service/content.go index c58757d..c6d1ec6 100644 --- a/internal/service/content.go +++ b/internal/service/content.go @@ -82,7 +82,9 @@ func (s *ContentService) ProcessIRCLink(item data.IRCLink) DisplayItem { matches := re.FindStringSubmatch(item.URL) if len(matches) > 1 { // standard embed code - embed := fmt.Sprintf(``, item.URL) + // Force twitter.com domain for embed compatibility as widgets.js might not support x.com fully yet + embedURL := strings.Replace(item.URL, "x.com", "twitter.com", 1) + embed := fmt.Sprintf(``, embedURL, item.Title) d.Content = template.HTML(embed) isTwitter = true } -- 2.51.2 From 36f5dcd06c26e67343fd6f2ef02842dd0e42b0fa Mon Sep 17 00:00:00 2001 From: Michael Stahnke Date: Fri, 23 Jan 2026 12:59:51 -0600 Subject: [PATCH 071/231] fix: Alignment of header buttons across screens --- internal/assets/css/screen.css | 29 ++-------------------------- internal/templates/views/header.html | 15 ++++++++++++++ 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/internal/assets/css/screen.css b/internal/assets/css/screen.css index 61c009a..6878364 100644 --- a/internal/assets/css/screen.css +++ b/internal/assets/css/screen.css @@ -152,20 +152,7 @@ body { color: var(--masthead-text); } -#theme-toggle { - background: none; - border: none; - cursor: pointer; - padding: 0; - margin-top: 10px; /* Adjust alignment */ - color: var(--masthead-text); - opacity: 0.5; - transition: opacity 0.2s; -} -#theme-toggle:hover { - opacity: 1; -} #sidebar { /* Grid placement */ @@ -571,14 +558,7 @@ body { } /* Material Symbols Sizing */ -.material-symbols-rounded { - font-variation-settings: - 'FILL' 0, - 'wght' 400, - 'GRAD' 0, - 'opsz' 24; - font-size: 20px; /* Default matches previous SVG size */ -} + /* Mobile Responsiveness */ @media (max-width: 768px) { @@ -596,12 +576,7 @@ body { align-items: flex-start; } - #theme-toggle { - position: absolute; - top: 0; - right: 0; - margin-top: 0; - } + #sidebar { margin-bottom: 40px; diff --git a/internal/templates/views/header.html b/internal/templates/views/header.html index 5234ee2..fb93620 100644 --- a/internal/templates/views/header.html +++ b/internal/templates/views/header.html @@ -141,6 +141,21 @@ [data-theme="dark"] .dropdown-content .link a { color: var(--link-color, #58a6ff); } + + .material-symbols-rounded { + font-family: 'Material Symbols Rounded'; + font-weight: normal; + font-style: normal; + font-size: 24px; /* Standard size */ + display: inline-block; + line-height: 1; + text-transform: none; + letter-spacing: normal; + word-wrap: normal; + white-space: nowrap; + direction: ltr; + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; + }