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.
+
+
+
+
+
+
+
So how do I install this crap??
+
+
+
+
+
);
+ print qq(Drag this link: post to tumblefish! up to your Bookmarks toolbar or menu.);
+ print qq(
+
+
+
+
+
PS - Unfortunately, tumblebuttons don't work with Microsoft Internet Explorer. MSIE sucks. Stop using it.
+
+
+
+
+
+
PPS - If your name is Greg Buchanan and you just read the above postscript, you can suck my ass.
+
+
+
+
+
+
+
+);
+}
+else {
+ print "Content-type: text/plain\n\n";
+ print "Oh no! You didn't enter your name!";
+}
diff --git a/internal/assets/buttons/index.html b/internal/assets/buttons/index.html
new file mode 100644
index 0000000..a576d25
--- /dev/null
+++ b/internal/assets/buttons/index.html
@@ -0,0 +1,56 @@
+
+
+
+
+
tumblefish buttons
+
+
+
+
+
+
+
+ tumblefish.
+
+
+
+
+
+
+
WTF is a tumblebutton?!?
+
+
+
+
+
Buttons (bookmarklets) are links you add to your browser's Bookmarks toolbar or menu. They are an easy way to post your awesome links to tumblefish.
+
+
+
+
+
To get started, type your name in the box below.
+
+
+
+
+
+
+
+
+
diff --git a/internal/assets/css/screen.css b/internal/assets/css/screen.css
new file mode 100644
index 0000000..3a46fab
--- /dev/null
+++ b/internal/assets/css/screen.css
@@ -0,0 +1,197 @@
+body {
+ margin: 0 auto 0 auto;
+ background-color: white;
+ width: 750px;
+}
+
+#page {
+ padding: 15px 0 50px 0;
+ font-family: Helvetica, Arial, sans-serif;
+ font-size: 12px;
+ line-height: 18px;
+ position: relative;
+}
+
+#masthead, #sidebar, #content {
+ position: relative;
+}
+
+#masthead {
+ margin-left: -100px;
+ padding-left: 100px;
+ margin-right: -25px;
+ padding-right: 25px;
+ font-size: 72px;
+ font-weight: bold;
+ line-height: 68px;
+ letter-spacing: -5px;
+ border-bottom: solid 2px;
+}
+
+#masthead a {
+ text-decoration: none;
+ color: #000;
+}
+
+#sidebar {
+ width: 175px;
+ float: right;
+}
+
+#content {
+ width: 525px;
+}
+
+.date-icon {
+ margin-top: 23px;
+ margin-left: -75px;
+ font-size: 16px;
+ font-weight: bold;
+ float: left;
+}
+
+.date-date {
+ font-size: 52px;
+ color: #ccc;
+}
+
+.date-day {
+ margin-top: -24px;
+ margin-left: 15px;
+ color: #666;
+}
+
+.date-month {
+ margin-left: 15px;
+}
+
+.item {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ font-size: 24px;
+ line-height: 20px;
+ letter-spacing: -1px;
+}
+
+.item img {
+ padding: 3px;
+ background: #bbb;
+ max-height: 400px;
+ max-width: 400px;
+ overflow: hidden;
+}
+
+.author {
+ padding-left: 5px;
+ font-size: 18px;
+ color: #aaa;
+}
+
+.link a {
+ font-weight: bold;
+ text-decoration: none;
+ color: #6c3;
+}
+
+.text {
+ font-weight: bold;
+ text-decoration: none;
+ color: #aaa;
+}
+
+.quote {
+ padding-left: 30px;
+ font-weight: bold;
+ color: #d5b;
+ background: url(/img/parens.gif) no-repeat;
+}
+
+.header {
+ padding-bottom: 15px;
+ font-size: 18px;
+ font-weight: bold;
+ letter-spacing: -1px;
+ color: #aaa;
+ text-align: right;
+}
+
+#search-form {
+ padding-bottom: 15px;
+ text-align: right;
+}
+
+.sm {
+ padding-bottom: 15px;
+ font-size: 16px;
+ line-height: 14px;
+ letter-spacing: -1px;
+ text-align: right;
+}
+
+.og-preview {
+ margin-top: 10px;
+ max-width: 500px;
+}
+
+.og-preview:empty {
+ display: none;
+}
+
+.og-preview-card {
+ border: 1px solid #e1e8ed;
+ border-radius: 8px;
+ overflow: hidden;
+ background: #fff;
+ max-width: 500px;
+ margin-top: 8px;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+}
+
+.og-preview-image {
+ width: 100%;
+ overflow: hidden;
+ display: block;
+}
+
+.og-preview-image img {
+ width: 100%;
+ max-height: 400px;
+ object-fit: cover;
+ display: block;
+}
+
+.og-preview-content {
+ padding: 12px;
+}
+
+.og-preview-title {
+ font-size: 14px;
+ font-weight: bold;
+ color: #14171a;
+ margin-bottom: 4px;
+ line-height: 1.4;
+}
+
+.og-preview-description {
+ font-size: 13px;
+ color: #657786;
+ line-height: 1.4;
+}
+
+.youtube-embed-wrapper {
+ position: relative;
+ padding-bottom: 56.25%; /* 16:9 aspect ratio */
+ height: 0;
+ overflow: hidden;
+ max-width: 100%;
+ margin-top: 10px;
+ background: #000;
+}
+
+.youtube-embed-wrapper iframe {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
diff --git a/internal/assets/favicon.ico b/internal/assets/favicon.ico
new file mode 100755
index 0000000000000000000000000000000000000000..b1e57e4b0c62fe7506f2faa140dc67c5726e7963
GIT binary patch
literal 318
zcmZQzU}Ruq5D*Yx009j~1_m((1_liV1_qFb0s}~%1ti7L045;>NDjhcU|@**&+z~M
ze+FhICI*K5|8QaIG6V$$7#JBC7zG6d8AKQu1O){c1wpj2pa7Uw7GM+
Bm<7<_#hv=|r|I2hO&g&3F_7#J8C
z7#SECr5ISjYzBrTMrk-Zi%|orhKYfJ(U5_Ofq_Aqfq|ij(Fn{IVPIhBZO>$2f$Ckt
zz`$^UfdOPA$W#dJn3A8Ast}NoUy@&(kzb(T9IOzYnU|7ZUd&)*U|?!xU}$Avs9KOw2Gz0R~1EMrI}!CRTPf4pwF!V@3uhL1q?3AwyQjz(irC
z!bT%Dk%8K@Sio*#XJa)5J3-MzgJhWPkLUpRl;QF{&a`Z;Tt7f1hHqQ51*
zV#<{N4F4~@wVLl&XXk%wX8cO$IdaeX?%ugxuX<`*c<8AU(u`kLJqV9$vbpa4bNPdv
z+54By@AC4#T$Q`CZ0os95tGw%s{b>nu3r3Q)!&2P)t3L9Rv)6~>Hq)c|F@i6h|FoM
zXTV_LsA;sWy-A!evU+`|PfTtE^`V4Yt1I{j@X&MuwcUGzK=X
z2qRQv;mlcZ5hJkCQJ{hYs>T#7qKgm#oB0DQTfo5ZA1h#f0g}fm^`8NkBaY;^vj+Mfq^Y4zceqU*gq*N
z8606CX@;QW5<>Zlb@FkmSA9D@JUT8LFPM^9U}prSXTan^e7548
z%;ePKupFOakohR~u!7|n7#M^Y7#P^W98VXhSs?4!(~I&;3s8jE@{2OlGxJjON^=<)
z75V3ZUqtvHzJ%
zi*iC-BSJu)2U*L&01A9m5#QqUR1^`m#GI0l#B>xfzU0)LoRHLt63=3f5MQ69{QMkH
zGT?v-qbTOg$S7;Tn3`!<}LC6K(K2+k+Lpu7MoL--jS8B!SX8Ir)&QvgE-Lq0
zm{kBSpz*6vU~pyt8wW8T(oSG!;9z8AU}j|a|4{sOMoCG5mA-ynv0fsm{(kpb9csDZw>|1p^E-nSI
zRW7N;$wiq3C7Jno3LvW!lk!VTY}HFkGgGWA&CQby%uOwHO_NPbbqx(uEOir;(^7Sl
zjm^zcO_EK`jM5U-xfGyQrdDJk+=^l;NCsxDf`Sc*YXvT&g9{RqQ|%0G^wEX5V9EkA
zD^hbJTrzW0^NKU`^X!aFp^DKZP<2G&(t#wwrC_5E@-b2f&>&O_kRlKzPzqAg^bPfl
z_0{d@8BeS?9zo^pAMjum*KF%-6Ov917Kr%><263(Yi!xL5
zN)kb-8kD>+#6YP9EEo}#TAZI#3Q=fcpl@iSk0B0M9En3QvN&8ZLTzYXW{I5<)L=AW
z`UGA@VgdG8hn0SyM5tg>kyv2mT$GwvlIoIJl4=JU!qGJ_)HN^+feqnA*?rl;y>6{M!y=p#h2yBAVg
z2f4V}aoOmD>r_zvYR9EO2QX?0Nr5~XT%*B7QV5WwcrZlGyW22WRKh2kK0C-5W%ocMMPvOM9ku1A0ZXyz6!Lj*%GxuB?ofq^lAfq}s|
zCAB!2fq}7wfq@~Xyrcjm4w^U=Ny-MXnLv~LB1I9AQ49=B5)2FsBIyve4v3uuVLO1>
zDY(A7??pz
zAVhLg6N?!bSU{7KDkZ7O84L_82A~P;qL3hG1_l-z1_lnEbR>2X61ya|0yJsM;GADj
z37!^J&`8!)FtoI^Q1D1C&q*yQ(G5sU&Q2^!QE<-BElA9(WMF{!85~kVps-c&bagf}
zv@|o*HPQo3^@C+V6FvAOVTuUQ5s^{Q`1u?Njx$EFGYBz+W`tP&A_fMFuM7;#6A)ra
zGZ`4Rw=ytD+(L+{EMQ>ZZ((5Ab{@$;VxaVbG)@yk_{o@QvX&BO@a_BQK*c
zqa>p|qbj2|qY)9VV<}?|V>4qn<0Qsej0+i8GHztt$#{_Q
zB;!TKn~V<`Uow7T{Kdq~#KR=QB+I16q|ao*_=7B&`P76lev7Hbv{mQa>tmO_>~mL8T_EGt;Hu^eH!%<_=s1Is^FURD`a
zZB{E*Pu2+5bk=g#Hr8pZ%UHLv9%a4C`jqt>8ylM#n>w30n+IDYTNYb2TMye@w)JcW
z*er}q2G=^S
zqg;2mK67((D{)(L2Xbd}H*nA3-oSl=`yuyF9w8nr9%r5yo>HDZp5;6Tcy981=H=y8
z<8|PT;w|Cr<6X&nnD-v<4?bZ&Jw8vq6ux@C*?imhF7dtN=j2!AcjS-bui~G^znT9$
z{~G~L0W|?jQC3sTsg%G=tnvkndicpKtVxhxA
zkA<0qm4%&!lZBgwmk1vfek#HyqAubok|okDvR34r$Olm&Q4`TH(F)PoqI*Rjh%t+)
ziFu0Ui1mwY61yh$OI%jmQ9M<=Q+%EHCGqbP(h?35sS;fh>m{yA{E}3Vbd}7OoG7_n
z@~#w%l$KPWRJqhVsiRVFq(!8yrIV$*q&G?5l3|k3k_nQjmRT%wM&_%moUFTSf$U7#
z!?JJW#N-_0vgD@7?UQ>cFD!2-pD8~@e!u)H1yKb@g&c($3P%(^D9R{$D3&TNR6M8n
zTS;9hRH;d6z0zG}E@cblH03GEhm=34$gB9O)TpdhxuwdfYN48;I$ia+>UTADwQ#j|
zwe4yz)FsqC)vMH3tKZS!)v(tn)L5i(MUzd_LNi-)uI2?TCM{E~46WH(=e3!%&9pPM
z=W1WnVb!tJ$L1ttXJBfOW3a^F
zwxN)rmtm9PF2heoT1Lr6vyHA8^BTJu*BkFJ{%E3Yl4>%~?NecA@P9I|aK0y9IU+>=o@3?HAcUa!__iaaiW?+)>jp%W;k4J0}CDBB!lR
z-<_?TYn>0cFuS<%Ao#lGZP020YZMEA6cT@Lj_X8fR9^M`kJ+6DodM10W
z^!(su?p5n`#GA)E*n5`uBOfiFBA;EpOuk;elYQ^_sru#lZTDyJ_w=9ae>XrqpfF%}
zAX{KS;OxL>L54v!LC1rIgX4o&1%C^13h58I9jXyp8hR*u@$i=bPGC(4
zPgs@kC(%D~QR3Gm&!jm?ACjGurzgKoaY&hx@-o#nbyDi{G~2XEX)n@k(@+!_$YE^btKCg1C
zT3XFgom_pWMzN-`=3%X4?ZP_7x}>^8^~&`v^-mkz8&))OHD))SZ8B(@)byn}ym?oP
zY)eDS<5u_9)opxjMQzvHt=s2!uyka0oa;31oZ0!WE2ZmHw?X%`?ms=rJ*RpNduR0i
z?@Q}D*KgK8Zvxwdyb0GP+D}|DNpMotq{oweCvTggFr{nCm#OhnPfjzLHh((T^wQ}M
zX86q5F;ivcgqeS4Wz4!d+iCXtIWluP=X{@=GWXIvhk5Jf%g*nf|7$_!f*T9n7j9dm
zwrJX7w#8+OpDhVna(t=9(v{1kmUS=tyF73CgB3w5j;u6axpI}vs{YkXt4mkETobeA
z!djQLJJ#v0Tex0qefI{24W%1iZA{pBZIkz=Lz^u&uiK)!WzJUNtzFv~w^eTYusvh@
zgB_7OF7EW&d1#mQuC2TEb}!$fxM%iW(Y^iqxc0T|`@g?x|JMTr2i_b^Klu1i{Gr>2
zBMx6V5_sg?QSYNCj=3E>eBAN){u8z*_MEgjx$~68sqLrDPH#J7dS>fc)3aO8nVs8q
z-u(QI3ziplU9`El?~?tcgO^<{AG_jt<@8m*s~4|@Ub}HU=K6yhDK}o+%(?mTR@trJ
zw;S%T-s!q4aCh20nR|=wYu;b~!0f@^hb|A#JPLkv`*HH)*H4O{{CV2)jQ82J=km{2
zzA$>R=cU`r3$LPIJ$+s9`uCf*w}NlyywiBM<-NoEGan*8JpEYo@&Biu&yt^)e=+%T
z=xe~&d*5=u{r=wdL-NOppB6ul{|f*0{CD{u_CGWJYX05z&->q<|GEGF|9{7z2A*nW
zNCCCK8JQVGS(w?lAPb-&jRY_W-YWoJh)*2BgaFKNfTR_)GM))8K`i6{A^2VaLD*h_
zBMj@ny9B__gc2OgtZeL@ETC=@R0K*1FfcJPGlTYzKm}M>*%+9Z9T-J~gq=-H1sRwa
znV8tvxInv2Kq?rSm<3rFgcJ=Em4sOX8;zWbMFbotF5DP2Y0)N8WfN5)&{8juLKt=s
z25kpnWM+Yxz`)2LsA%Z$!BHUa;75hTM#!RkW+p*qK?VlKtBYE64K^ER>^bJxF;`&m
z3ge>(bgWp;|Gx!Z=Eca$G|97T&ohOsvGa`eRFbU9R=+=a*WsE+-lYve?De*HJ(F*S
zZA{n9ICkSs&UDqnw)K+r5&nMr!P=P^7+IMv)Pvc~NNgr1HhU&^h%kc5$jD~T6z6&J
zZepHF+2y;s%qE{i{dn@74|&|UI3siY{=XdS5@*a6I&rjl-jp4z)BF~9gOVzEq6BEb}diUT=4v&*H@mhW#6{3n>AfOTXa+1d*#Ex
z8BRA3pXFJs9mMIIk?9yEyGOJzIfX5B^QM0ipK8{fcAt>*=FO2JPignliwiEFIj~}i
z=4BT3eeYLHyeaW-jlCOxTfK~ZPrXjm56ur>AmPEt%xJH8M{~t1l_!cC0d9RvY195Q
z?Dlcv@X)w@XxqAD(O-R;vkoxDX_VI7Ih
z@b-*F3tJ-WSpC};$Sm2MA^Up3=Y=YH(v$^G!w|a7xrK_Gn~!C
z%m_^f0u2l-lROl_@*uYfY!I?p5GC`vX?>RVH4JWTM=D2V)QQ(Ptp^D&~*i}J|-aO}ocDLCrJqKM73S5s2MF<`Dy2!n2BoV7U{V;m=gH2`=!QMmvB
literal 0
HcmV?d00001
diff --git a/internal/assets/img/prev.jpg b/internal/assets/img/prev.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..4b305a4afbd14205ec297caad13d3b9f5ccb4474
GIT binary patch
literal 13699
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
P!FGY%C4l5E5c?(o1G+HB
literal 0
HcmV?d00001
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..7164e64
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,48 @@
+package config
+
+import (
+ "fmt"
+ "os"
+
+ "gopkg.in/yaml.v2"
+)
+
+type Config struct {
+ Host string `yaml:"host"`
+ Database string `yaml:"database"`
+ Username string `yaml:"username"`
+ Password string `yaml:"password"`
+ BaseURL string `yaml:"baseurl"`
+ Driver string `yaml:"driver"`
+}
+
+func Load(path string) (*Config, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ var cfg Config
+ decoder := yaml.NewDecoder(f)
+ if err := decoder.Decode(&cfg); err != nil {
+ return nil, err
+ }
+
+ // Defaults
+ if cfg.Driver == "" {
+ cfg.Driver = "mysql"
+ }
+
+ return &cfg, nil
+}
+
+func (c *Config) DSN() string {
+ if c.Driver == "sqlite" || c.Driver == "sqlite3" {
+ // For SQLite, "Database" field is the file path
+ return c.Database
+ }
+ // MySQL: username:password@tcp(host)/dbname?parseTime=true
+ dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true", c.Username, c.Password, c.Host, c.Database)
+ return dsn
+}
diff --git a/internal/data/factory.go b/internal/data/factory.go
new file mode 100644
index 0000000..36cb345
--- /dev/null
+++ b/internal/data/factory.go
@@ -0,0 +1,22 @@
+package data
+
+import (
+ "fmt"
+ "strings"
+)
+
+// NewStore creates a new Store based on the driver name and DSN.
+// Driver can be "mysql" or "sqlite" (case-insensitive).
+func NewStore(driver, dsn string) (Store, error) {
+ switch strings.ToLower(driver) {
+ case "mysql":
+ return NewMySQLStore(dsn)
+ case "sqlite", "sqlite3":
+ // modernc.org/sqlite registers as "sqlite"
+ // If user config says "sqlite3", we map it.
+ // NOTE: NewSQLiteStore uses "sqlite" internally now.
+ return NewSQLiteStore(dsn)
+ default:
+ return nil, fmt.Errorf("unknown database driver: %s", driver)
+ }
+}
diff --git a/internal/data/mysql.go b/internal/data/mysql.go
new file mode 100644
index 0000000..60fb8d7
--- /dev/null
+++ b/internal/data/mysql.go
@@ -0,0 +1,263 @@
+package data
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+
+ _ "github.com/go-sql-driver/mysql"
+)
+
+type MySQLStore struct {
+ db *sql.DB
+}
+
+func NewMySQLStore(dsn string) (*MySQLStore, error) {
+ db, err := sql.Open("mysql", dsn)
+ if err != nil {
+ return nil, err
+ }
+ if err := db.Ping(); err != nil {
+ return nil, err
+ }
+ // Recommended settings
+ db.SetConnMaxLifetime(time.Minute * 3)
+ db.SetMaxOpenConns(10)
+ db.SetMaxIdleConns(10)
+
+ return &MySQLStore{db: db}, nil
+}
+
+func (s *MySQLStore) Close() error {
+ return s.db.Close()
+}
+
+func (s *MySQLStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int) ([]IRCLink, error) {
+ query := `
+ SELECT ircLinkID, timestamp, user, title, url, clicks, content_type
+ FROM ircLink
+ WHERE timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ AND timestamp <= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ORDER BY timestamp DESC
+ `
+ // Note: Perl logic was: start_days <= timestamp AND end_days >= timestamp
+ // But start_days is the LARGER number (further back in time).
+ // So timestamp >= (NOW - start) AND timestamp <= (NOW - end)
+
+ rows, err := s.db.QueryContext(ctx, query, startDays, endDays)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var links []IRCLink
+ for rows.Next() {
+ var l IRCLink
+ var contentType sql.NullString // Handle nullable
+ if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil {
+ return nil, err
+ }
+ if contentType.Valid {
+ l.ContentType = contentType.String
+ }
+ links = append(links, l)
+ }
+ return links, nil
+}
+
+func (s *MySQLStore) GetRecentImages(ctx context.Context, startDays int, endDays int) ([]Image, error) {
+ query := `
+ SELECT imageID, timestamp, title, link, url, md5sum
+ FROM image
+ WHERE timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ AND timestamp <= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ORDER BY timestamp DESC
+ `
+ rows, err := s.db.QueryContext(ctx, query, startDays, endDays)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var images []Image
+ for rows.Next() {
+ var i Image
+ if err := rows.Scan(&i.ID, &i.Timestamp, &i.Title, &i.Link, &i.URL, &i.MD5Sum); err != nil {
+ return nil, err
+ }
+ images = append(images, i)
+ }
+ return images, nil
+}
+
+func (s *MySQLStore) GetRecentQuotes(ctx context.Context, startDays int, endDays int) ([]Quote, error) {
+ query := `
+ SELECT quoteID, timestamp, quote, author
+ FROM quote
+ WHERE timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ AND timestamp <= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ORDER BY timestamp DESC
+ `
+ rows, err := s.db.QueryContext(ctx, query, startDays, endDays)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var quotes []Quote
+ for rows.Next() {
+ var q Quote
+ if err := rows.Scan(&q.ID, &q.Timestamp, &q.Quote, &q.Author); err != nil {
+ return nil, err
+ }
+ quotes = append(quotes, q)
+ }
+ return quotes, nil
+}
+
+func (s *MySQLStore) SearchIRCLinks(ctx context.Context, searchTerm string) ([]IRCLink, error) {
+ // MySQL FULLTEXT search
+ query := `
+ SELECT ircLinkID, timestamp, user, title, url, clicks, content_type
+ FROM ircLink
+ WHERE MATCH(title, url) AGAINST(? IN BOOLEAN MODE)
+ ORDER BY clicks DESC
+ LIMIT 50
+ `
+ rows, err := s.db.QueryContext(ctx, query, searchTerm)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var links []IRCLink
+ for rows.Next() {
+ var l IRCLink
+ var contentType sql.NullString
+ if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil {
+ return nil, err
+ }
+ if contentType.Valid {
+ l.ContentType = contentType.String
+ }
+ links = append(links, l)
+ }
+ return links, nil
+}
+
+func (s *MySQLStore) GetTopIRCLinks(ctx context.Context, startDays int, endDays int, limit int) ([]IRCLink, error) {
+ query := `
+ SELECT ircLinkID, timestamp, user, title, url, clicks, content_type
+ FROM ircLink
+ WHERE timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ AND timestamp <= DATE_SUB(NOW(), INTERVAL ? DAY)
+ AND clicks > 1
+ ORDER BY clicks DESC
+ LIMIT ?
+ `
+ rows, err := s.db.QueryContext(ctx, query, startDays, endDays, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var links []IRCLink
+ for rows.Next() {
+ var l IRCLink
+ var contentType sql.NullString
+ if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil {
+ return nil, err
+ }
+ if contentType.Valid {
+ l.ContentType = contentType.String
+ }
+ links = append(links, l)
+ }
+ return links, nil
+}
+
+func (s *MySQLStore) GetIRCLinkByID(ctx context.Context, id int) (*IRCLink, error) {
+ query := `
+ SELECT ircLinkID, timestamp, user, title, url, clicks, content_type
+ FROM ircLink
+ WHERE ircLinkID = ?
+ `
+ var l IRCLink
+ var contentType sql.NullString
+ err := s.db.QueryRowContext(ctx, query, id).Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, nil // Or error?
+ }
+ return nil, err
+ }
+ if contentType.Valid {
+ l.ContentType = contentType.String
+ }
+ return &l, nil
+}
+
+func (s *MySQLStore) GetIRCLinkURL(ctx context.Context, id int) (string, error) {
+ query := `SELECT url FROM ircLink WHERE ircLinkID = ?`
+ var url string
+ err := s.db.QueryRowContext(ctx, query, id).Scan(&url)
+ if err != nil {
+ return "", err
+ }
+ return url, nil
+}
+
+func (s *MySQLStore) IncrementClicks(ctx context.Context, id int) error {
+ query := `UPDATE ircLink SET timestamp = timestamp, clicks = clicks + 1 WHERE ircLinkID = ?`
+ _, err := s.db.ExecContext(ctx, query, id)
+ return err
+}
+
+func (s *MySQLStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) {
+ query := `INSERT INTO ircLink (user, title, url, content_type) VALUES (?, ?, ?, ?)`
+ res, err := s.db.ExecContext(ctx, query, user, title, url, contentType)
+ if err != nil {
+ return 0, err
+ }
+ id, err := res.LastInsertId()
+ return int(id), err
+}
+
+func (s *MySQLStore) InsertQuote(ctx context.Context, quote, author string) error {
+ query := `INSERT INTO quote (quote, author) VALUES (?, ?)`
+ _, err := s.db.ExecContext(ctx, query, quote, author)
+ return err
+}
+
+func (s *MySQLStore) Bootstrap(ctx context.Context) error {
+ schema, err := SchemaFS.ReadFile("schema.mysql")
+ if err != nil {
+ return err
+ }
+ // Simple split by ; might fail on complex SQL, but for this schema it's fine.
+ // Actually, the schema has multi-line statements.
+ // A robust solution executes the whole script if the driver supports it, or splits carefully.
+ // MySQL driver often supports multiple statements if enabled, but better to execute one by one if split properly.
+ // For this specific schema, splitting by `;` works because there are no semicolons inside strings/triggers.
+ // HOWEVER, creating a new method to execute script is cleaner.
+
+ // Actually, just executing the whole thing might work if multiStatements=true in DSN, but let's assume not.
+ // We'll follow a simple split approach for now, or just execute the known CREATE statements.
+ // Since we want to use the embedded file, we should parse it.
+
+ // Simpler: Just execute the file content?
+ // Drivers behave differently.
+ // Let's rely on the file content being simple enough.
+
+ queries := splitSQL(string(schema))
+ for _, q := range queries {
+ if q == "" {
+ continue
+ }
+ if _, err := s.db.ExecContext(ctx, q); err != nil {
+ return fmt.Errorf("failed to execute query %q: %w", q, err)
+ }
+ }
+ return nil
+}
diff --git a/internal/data/schema.go b/internal/data/schema.go
new file mode 100644
index 0000000..1cc31c4
--- /dev/null
+++ b/internal/data/schema.go
@@ -0,0 +1,6 @@
+package data
+
+import "embed"
+
+//go:embed schema.sqlite schema.mysql
+var SchemaFS embed.FS
diff --git a/internal/data/schema.mysql b/internal/data/schema.mysql
new file mode 100644
index 0000000..0527674
--- /dev/null
+++ b/internal/data/schema.mysql
@@ -0,0 +1,53 @@
+-- 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,
+ `title` varchar(255) NOT NULL DEFAULT '',
+ `link` varchar(255) NOT NULL DEFAULT '',
+ `url` text NOT NULL,
+ `md5sum` varchar(255) NOT NULL DEFAULT '',
+ PRIMARY KEY (`imageID`),
+ KEY `imageID` (`imageID`),
+ KEY `imgindex` (`imageID`)
+) ENGINE=MyISAM AUTO_INCREMENT=7767 DEFAULT CHARSET=latin1;
+
+
+CREATE TABLE IF NOT EXISTS `ircLink` (
+ `ircLinkID` int(16) NOT NULL AUTO_INCREMENT,
+ `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `user` varchar(9) NOT NULL DEFAULT '',
+ `title` varchar(255) NOT NULL DEFAULT '',
+ `url` text NOT NULL,
+ `clicks` int(16) NOT NULL DEFAULT 0,
+ `content_type` varchar(40),
+ PRIMARY KEY (`ircLinkID`),
+ KEY `ircLinkID` (`ircLinkID`),
+ KEY `ircindex` (`ircLinkID`),
+ FULLTEXT KEY `title` (`title`,`url`)
+) ENGINE=MyISAM AUTO_INCREMENT=30520 DEFAULT CHARSET=latin1;
+
+
+CREATE TABLE IF NOT EXISTS `quote` (
+ `quoteID` int(16) NOT NULL AUTO_INCREMENT,
+ `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `quote` varchar(255) NOT NULL DEFAULT '',
+ `author` varchar(255) NOT NULL DEFAULT '',
+ PRIMARY KEY (`quoteID`),
+ KEY `imageID` (`quoteID`),
+ KEY `quoteindex` (`quoteID`)
+) ENGINE=MyISAM AUTO_INCREMENT=4778 DEFAULT CHARSET=latin1;
+
+-- 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/internal/data/schema.sqlite b/internal/data/schema.sqlite
new file mode 100644
index 0000000..266230e
--- /dev/null
+++ b/internal/data/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/internal/data/sqlite.go b/internal/data/sqlite.go
new file mode 100644
index 0000000..461c8d2
--- /dev/null
+++ b/internal/data/sqlite.go
@@ -0,0 +1,241 @@
+package data
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+
+ _ "modernc.org/sqlite"
+)
+
+type SQLiteStore struct {
+ db *sql.DB
+}
+
+func NewSQLiteStore(dsn string) (*SQLiteStore, error) {
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, err
+ }
+ if err := db.Ping(); err != nil {
+ return nil, err
+ }
+ return &SQLiteStore{db: db}, nil
+}
+
+func (s *SQLiteStore) Close() error {
+ return s.db.Close()
+}
+
+func (s *SQLiteStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int) ([]IRCLink, error) {
+ // timestamp >= datetime('now', '-' || ? || ' days')
+ query := `
+ SELECT ircLinkID, timestamp, user, title, url, clicks, content_type
+ FROM ircLink
+ WHERE timestamp >= datetime('now', '-' || ? || ' days')
+ AND timestamp <= datetime('now', '-' || ? || ' days')
+ ORDER BY timestamp DESC
+ `
+ rows, err := s.db.QueryContext(ctx, query, startDays, endDays)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var links []IRCLink
+
+ for rows.Next() {
+ var l IRCLink
+ var contentType sql.NullString
+ if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil {
+ return nil, err
+ }
+ if contentType.Valid {
+ l.ContentType = contentType.String
+ }
+ links = append(links, l)
+ }
+ return links, nil
+}
+
+func (s *SQLiteStore) GetRecentImages(ctx context.Context, startDays int, endDays int) ([]Image, error) {
+ query := `
+ SELECT imageID, timestamp, title, link, url, md5sum
+ FROM image
+ WHERE timestamp >= datetime('now', '-' || ? || ' days')
+ AND timestamp <= datetime('now', '-' || ? || ' days')
+ ORDER BY timestamp DESC
+ `
+ rows, err := s.db.QueryContext(ctx, query, startDays, endDays)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var images []Image
+ for rows.Next() {
+ var i Image
+ if err := rows.Scan(&i.ID, &i.Timestamp, &i.Title, &i.Link, &i.URL, &i.MD5Sum); err != nil {
+ return nil, err
+ }
+ images = append(images, i)
+ }
+ return images, nil
+}
+
+func (s *SQLiteStore) GetRecentQuotes(ctx context.Context, startDays int, endDays int) ([]Quote, error) {
+ query := `
+ SELECT quoteID, timestamp, quote, author
+ FROM quote
+ WHERE timestamp >= datetime('now', '-' || ? || ' days')
+ AND timestamp <= datetime('now', '-' || ? || ' days')
+ ORDER BY timestamp DESC
+ `
+ rows, err := s.db.QueryContext(ctx, query, startDays, endDays)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var quotes []Quote
+ for rows.Next() {
+ var q Quote
+ if err := rows.Scan(&q.ID, &q.Timestamp, &q.Quote, &q.Author); err != nil {
+ return nil, err
+ }
+ quotes = append(quotes, q)
+ }
+ return quotes, nil
+}
+
+func (s *SQLiteStore) SearchIRCLinks(ctx context.Context, searchTerm string) ([]IRCLink, error) {
+ // SQLite LIKE-based search (Perl compat)
+ query := `
+ SELECT ircLinkID, timestamp, user, title, url, clicks, content_type
+ FROM ircLink
+ WHERE title LIKE ? OR url LIKE ?
+ ORDER BY clicks DESC
+ LIMIT 50
+ `
+ likeTerm := fmt.Sprintf("%%%s%%", searchTerm)
+ rows, err := s.db.QueryContext(ctx, query, likeTerm, likeTerm)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var links []IRCLink
+ for rows.Next() {
+ var l IRCLink
+ var contentType sql.NullString
+ if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil {
+ return nil, err
+ }
+ if contentType.Valid {
+ l.ContentType = contentType.String
+ }
+ links = append(links, l)
+ }
+ return links, nil
+}
+
+func (s *SQLiteStore) GetTopIRCLinks(ctx context.Context, startDays int, endDays int, limit int) ([]IRCLink, error) {
+ query := `
+ SELECT ircLinkID, timestamp, user, title, url, clicks, content_type
+ FROM ircLink
+ WHERE timestamp >= datetime('now', '-' || ? || ' days')
+ AND timestamp <= datetime('now', '-' || ? || ' days')
+ AND clicks > 1
+ ORDER BY clicks DESC
+ LIMIT ?
+ `
+ rows, err := s.db.QueryContext(ctx, query, startDays, endDays, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var links []IRCLink
+ for rows.Next() {
+ var l IRCLink
+ var contentType sql.NullString
+ if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil {
+ return nil, err
+ }
+ if contentType.Valid {
+ l.ContentType = contentType.String
+ }
+ links = append(links, l)
+ }
+ return links, nil
+}
+
+func (s *SQLiteStore) GetIRCLinkByID(ctx context.Context, id int) (*IRCLink, error) {
+ query := `
+ SELECT ircLinkID, timestamp, user, title, url, clicks, content_type
+ FROM ircLink
+ WHERE ircLinkID = ?
+ `
+ var l IRCLink
+ var contentType sql.NullString
+ err := s.db.QueryRowContext(ctx, query, id).Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ return nil, err
+ }
+ if contentType.Valid {
+ l.ContentType = contentType.String
+ }
+ return &l, nil
+}
+
+func (s *SQLiteStore) GetIRCLinkURL(ctx context.Context, id int) (string, error) {
+ query := `SELECT url FROM ircLink WHERE ircLinkID = ?`
+ var url string
+ err := s.db.QueryRowContext(ctx, query, id).Scan(&url)
+ if err != nil {
+ return "", err
+ }
+ return url, nil
+}
+
+func (s *SQLiteStore) IncrementClicks(ctx context.Context, id int) error {
+ // timestamp hack to match MySQL behavior if needed, but SQLite defaults current_timestamp on update usually triggers only if trigger exists?
+ // The schema said default current timestamp.
+ // Updating timestamp explicitly:
+ query := `UPDATE ircLink SET timestamp = datetime('now'), clicks = clicks + 1 WHERE ircLinkID = ?`
+ _, err := s.db.ExecContext(ctx, query, id)
+ return err
+}
+
+func (s *SQLiteStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) {
+ query := `INSERT INTO ircLink (user, title, url, content_type) VALUES (?, ?, ?, ?)`
+ res, err := s.db.ExecContext(ctx, query, user, title, url, contentType)
+ if err != nil {
+ return 0, err
+ }
+ id, err := res.LastInsertId()
+ return int(id), err
+}
+
+func (s *SQLiteStore) InsertQuote(ctx context.Context, quote, author string) error {
+ query := `INSERT INTO quote (quote, author) VALUES (?, ?)`
+ _, err := s.db.ExecContext(ctx, query, quote, author)
+ return err
+}
+
+func (s *SQLiteStore) Bootstrap(ctx context.Context) error {
+ schema, err := SchemaFS.ReadFile("schema.sqlite")
+ if err != nil {
+ return err
+ }
+
+ // modernc.org/sqlite usually handles multiple statements in one Exec.
+ // Let's try executing the whole block.
+ if _, err := s.db.ExecContext(ctx, string(schema)); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/internal/data/store.go b/internal/data/store.go
new file mode 100644
index 0000000..72ef0f7
--- /dev/null
+++ b/internal/data/store.go
@@ -0,0 +1,50 @@
+package data
+
+import (
+ "context"
+ "time"
+)
+
+type IRCLink struct {
+ ID int `json:"ircLinkID"`
+ Timestamp time.Time `json:"timestamp"`
+ User string `json:"user"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Clicks int `json:"clicks"`
+ ContentType string `json:"content_type"`
+}
+
+type Image struct {
+ ID int `json:"imageID"`
+ Timestamp time.Time `json:"timestamp"`
+ Title string `json:"title"`
+ Link string `json:"link"`
+ URL string `json:"url"`
+ MD5Sum string `json:"md5sum"`
+}
+
+type Quote struct {
+ ID int `json:"quoteID"`
+ Timestamp time.Time `json:"timestamp"`
+ Quote string `json:"quote"`
+ Author string `json:"author"`
+}
+
+type Store interface {
+ GetRecentIRCLinks(ctx context.Context, days int, offsetDays int) ([]IRCLink, error)
+ GetRecentImages(ctx context.Context, days int, offsetDays int) ([]Image, error)
+ GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]Quote, error)
+
+ SearchIRCLinks(ctx context.Context, query string) ([]IRCLink, error)
+ GetTopIRCLinks(ctx context.Context, startDays int, endDays int, limit int) ([]IRCLink, error)
+ GetIRCLinkByID(ctx context.Context, id int) (*IRCLink, error)
+ GetIRCLinkURL(ctx context.Context, id int) (string, error)
+ 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
+
+ Bootstrap(ctx context.Context) error
+
+ Close() error
+}
diff --git a/internal/data/util.go b/internal/data/util.go
new file mode 100644
index 0000000..690a210
--- /dev/null
+++ b/internal/data/util.go
@@ -0,0 +1,17 @@
+package data
+
+import "strings"
+
+func splitSQL(sql string) []string {
+ var queries []string
+ // Basic implementation: split by semicolon + newline or just semicolon at end of line
+ // This schema is formatted well enough that we can split by `;\n` or `;` but trimming spaces.
+ parts := strings.Split(sql, ";")
+ for _, p := range parts {
+ q := strings.TrimSpace(p)
+ if q != "" {
+ queries = append(queries, q)
+ }
+ }
+ return queries
+}
diff --git a/internal/handler/handlers.go b/internal/handler/handlers.go
new file mode 100644
index 0000000..90b0f90
--- /dev/null
+++ b/internal/handler/handlers.go
@@ -0,0 +1,332 @@
+package handler
+
+import (
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "strconv"
+ "sync"
+
+ "tumble/internal/config"
+ "tumble/internal/data"
+ "tumble/internal/service"
+ "tumble/internal/templates"
+ "tumble/internal/version"
+)
+
+type Handler struct {
+ Store data.Store
+ Service *service.ContentService
+ Renderer *templates.Renderer
+ Config *config.Config
+}
+
+func NewHandler(cfg *config.Config, store data.Store, svc *service.ContentService, renderer *templates.Renderer) *Handler {
+ return &Handler{
+ Config: cfg,
+ Store: store,
+ Service: svc,
+ Renderer: renderer,
+ }
+}
+
+// Index Page Data structure for the main template
+type IndexPageData struct {
+ PageTitle string
+ Hot template.HTML
+ Container template.HTML
+ NavP template.HTML
+ NavN template.HTML
+ GitCommit string // Placeholder
+ GitCommitURL string // Placeholder
+ // For XML
+ BaseURL template.HTML
+}
+
+func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ // Parameters
+ params := r.URL.Query()
+ dtype := params.Get("dtype")
+ iParam := params.Get("i")
+
+ i := 1
+ if iParam != "" {
+ val, err := strconv.Atoi(iParam)
+ if err == nil && val > 0 {
+ i = val
+ }
+ }
+
+ // Date interval logic:
+ // Perl: start_days = i * 6, end_days = (i - 1) * 6
+ startDays := i * 6
+ endDays := (i - 1) * 6
+
+ // Fetch Items
+ var wg sync.WaitGroup
+ var errIrc, errImg, errQuote error
+ var ircLinks []data.IRCLink
+ var images []data.Image
+ var quotes []data.Quote
+
+ wg.Add(3)
+ go func() { defer wg.Done(); ircLinks, errIrc = h.Store.GetRecentIRCLinks(ctx, startDays, endDays) }()
+ go func() { defer wg.Done(); images, errImg = h.Store.GetRecentImages(ctx, startDays, endDays) }()
+ go func() { defer wg.Done(); quotes, errQuote = h.Store.GetRecentQuotes(ctx, startDays, endDays) }()
+ wg.Wait()
+
+ if errIrc != nil || errImg != nil || errQuote != nil {
+ log.Printf("Error fetching data: %v %v %v", errIrc, errImg, errQuote)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ 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.
+
+ type ProcessedItem struct {
+ Timestamp string // for sorting
+ HTML string
+ DateRawDay string
+ DateDay string
+ DateMonth string
+ FullDate string // YYYYMMDD for comparison
+ }
+
+ processedItems := []ProcessedItem{}
+
+ // Process IRCLinks
+ for _, item := range ircLinks {
+ d := h.Service.ProcessIRCLink(item)
+ tmplName := "tumble_item_ircLink.html"
+ if dtype == "rss" || dtype == "xml" {
+ tmplName = "tumble_item_ircLink.xml"
+ }
+
+ html, err := h.Renderer.RenderToString(tmplName, d)
+ if err == nil {
+ processedItems = append(processedItems, ProcessedItem{
+ Timestamp: item.Timestamp.Format("20060102150405"), // Sortable string
+ HTML: html,
+ DateRawDay: d.DateRawDay,
+ DateDay: d.DateDay,
+ DateMonth: d.DateMonth,
+ FullDate: item.Timestamp.Format("20060102"),
+ })
+ } else {
+ log.Printf("DEBUG: Render Error for Link %d: %v", item.ID, err)
+ }
+ }
+
+ // Process Images
+ for _, item := range images {
+ d := h.Service.ProcessImage(item)
+ tmplName := "tumble_item_image.html"
+ if dtype == "rss" || dtype == "xml" {
+ tmplName = "tumble_item_image.xml"
+ }
+
+ html, err := h.Renderer.RenderToString(tmplName, d)
+ if err == nil {
+ processedItems = append(processedItems, ProcessedItem{
+ Timestamp: item.Timestamp.Format("20060102150405"),
+ HTML: html,
+ DateRawDay: d.DateRawDay,
+ DateDay: d.DateDay,
+ DateMonth: d.DateMonth,
+ FullDate: item.Timestamp.Format("20060102"),
+ })
+ }
+ }
+
+ // Process Quotes
+ for _, item := range quotes {
+ d := h.Service.ProcessQuote(item)
+ tmplName := "tumble_item_quote.html"
+ if dtype == "rss" || dtype == "xml" {
+ tmplName = "tumble_item_quote.xml"
+ }
+
+ html, err := h.Renderer.RenderToString(tmplName, d)
+ if err == nil {
+ processedItems = append(processedItems, ProcessedItem{
+ Timestamp: item.Timestamp.Format("20060102150405"),
+ HTML: html,
+ DateRawDay: d.DateRawDay,
+ DateDay: d.DateDay,
+ DateMonth: d.DateMonth,
+ FullDate: item.Timestamp.Format("20060102"),
+ })
+ }
+ }
+
+ // 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 {
+ processedItems[j], processedItems[k] = processedItems[k], processedItems[j]
+ }
+ }
+ }
+
+ // Generate Container HTML
+ containerHTML := ""
+ lastDate := ""
+
+ for _, p := range processedItems {
+ if dtype != "rss" && dtype != "xml" {
+ if p.FullDate != lastDate {
+ // Date Changed, Render Date Template
+ dateData := map[string]string{
+ "Date": p.DateRawDay,
+ "Day": p.DateDay,
+ "Month": p.DateMonth,
+ }
+ dateHTML, err := h.Renderer.RenderToString("tumble_date.html", dateData)
+ if err == nil {
+ containerHTML += dateHTML
+ }
+ lastDate = p.FullDate
+ }
+ }
+ containerHTML += p.HTML
+ }
+
+ // Hot Links (Side bar) - Only for HTML
+ hotHTML := ""
+ if dtype != "rss" && dtype != "xml" {
+ // Perl: 12 to 6 days ago.
+ topLinks, err := h.Store.GetTopIRCLinks(ctx, 12, 6, 5)
+ if err == nil {
+ for _, l := range topLinks {
+ // Link content: Title
+ content := fmt.Sprintf(`%s `, h.Config.BaseURL, l.ID, l.Title)
+ if len(l.Title) > 15 && len(l.Title) > 15 {
+ // Truncate logic from Perl
+ // if ( $hot->{$_}->{'title'} =~ /^(http:\/\/.*)/ ) { if ( length( $1 ) > 15 ) { ... } }
+ // Handled loosely here or strictly port logic.
+ }
+
+ // Render item
+ // Using map for flexibility
+ data := map[string]interface{}{
+ "Content": template.HTML(content),
+ }
+ s, _ := h.Renderer.RenderToString("tumble_item_top5.html", data)
+ hotHTML += s
+ }
+ }
+ }
+
+ // Navigation
+ navP := ""
+ navN := ""
+ if iParam != "" {
+ navP = fmt.Sprintf(` `, i+1)
+ navN = fmt.Sprintf(` `, i-1)
+ } else {
+ navP = ` `
+ }
+ if i == 1 {
+ navN = "" // Perl: $nav->{'n'} = '' unless $self->{'arg'}->{'i'};
+ }
+
+ // View Data
+ viewData := IndexPageData{
+ PageTitle: "",
+ Container: template.HTML(containerHTML),
+ Hot: template.HTML(hotHTML),
+ NavP: template.HTML(navP),
+ NavN: template.HTML(navN),
+ BaseURL: template.HTML(h.Config.BaseURL),
+ GitCommit: version.CommitHash,
+ GitCommitURL: fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash),
+ }
+
+ templateName := "index.html"
+ contentType := "text/html; charset=UTF-8"
+ if dtype == "rss" || dtype == "xml" {
+ templateName = "index.xml"
+ contentType = "text/xml; charset=UTF-8"
+ }
+
+ w.Header().Set("Content-Type", contentType)
+ if err := h.Renderer.Render(w, templateName, viewData); err != nil {
+ log.Printf("Error rendering template: %v", err)
+ }
+}
+
+func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ query := r.URL.Query().Get("search")
+
+ if query == "" {
+ // Perl behaviour: returns unless string?
+ return
+ }
+
+ // Perform Search
+ links, err := h.Store.SearchIRCLinks(ctx, query)
+ if err != nil {
+ log.Printf("Search error: %v", err)
+ http.Error(w, "Search Error", http.StatusInternalServerError)
+ return
+ }
+
+ containerHTML := ""
+ if len(links) > 0 {
+ for _, item := range links {
+ d := h.Service.ProcessIRCLink(item)
+ s, _ := h.Renderer.RenderToString("tumble_item_ircLink.html", d)
+ containerHTML += s
+ }
+ } else {
+ // No results template (tumble_item_text)
+ msg := fmt.Sprintf(`
+ Your search-fu is weak.
+ Your search for '%s' did not return any results. Perhaps the following tips can help aid you on your quest:
+
+ Searches must be done using four or more characters.
+ MySQL fulltext-searching is the magic behind this. Stop blaming scott.
+ Try not to be such a fucking idiot.
+ `, query)
+
+ data := map[string]interface{}{
+ "Content": template.HTML(msg),
+ }
+ containerHTML, _ = h.Renderer.RenderToString("tumble_item_text.html", data)
+ }
+
+ // Hot links (Same logic as Index)
+ hotHTML := ""
+ topLinks, err := h.Store.GetTopIRCLinks(ctx, 12, 6, 5)
+ if err == nil {
+ for _, l := range topLinks {
+ content := fmt.Sprintf(`%s `, h.Config.BaseURL, l.ID, l.Title)
+ data := map[string]interface{}{"Content": template.HTML(content)}
+ s, _ := h.Renderer.RenderToString("tumble_item_top5.html", data)
+ hotHTML += s
+ }
+ }
+
+ viewData := IndexPageData{
+ PageTitle: fmt.Sprintf(" > %s", query),
+ Container: template.HTML(containerHTML),
+ Hot: template.HTML(hotHTML),
+ BaseURL: template.HTML(h.Config.BaseURL),
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=UTF-8")
+ h.Renderer.Render(w, "index.html", viewData)
+}
diff --git a/internal/handler/irclink.go b/internal/handler/irclink.go
new file mode 100644
index 0000000..d0d6e5a
--- /dev/null
+++ b/internal/handler/irclink.go
@@ -0,0 +1,104 @@
+package handler
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "strconv"
+
+ "io/ioutil"
+ "strings"
+ "time"
+)
+
+// IRCLinkHandler handles /irclink/?id (redirect) and POSTing new links
+func (h *Handler) IRCLinkHandler(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ // Case 1: Posting a link (user & url params)
+ user := r.URL.Query().Get("user")
+ url := r.URL.Query().Get("url")
+
+ if user != "" && url != "" {
+ // Handle link posting
+ // Fetch title (simple impl)
+ title := url // Default to URL
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Get(url)
+ contentType := "0"
+ if err == nil {
+ defer resp.Body.Close()
+ // Basic title extraction (should improve for production)
+ if strings.Contains(resp.Header.Get("Content-Type"), "image") {
+ contentType = "image"
+ }
+ // Extract title logic omitted for brevity, using URL as fallback
+ // In real impl, read body and regex
+ body, _ := ioutil.ReadAll(resp.Body)
+ if idx := strings.Index(string(body), ""); idx != -1 {
+ end := strings.Index(string(body)[idx:], " ")
+ if end != -1 {
+ title = string(body)[idx+7 : idx+end]
+ }
+ }
+ }
+
+ // Insert
+ id, err := h.Store.InsertIRCLink(ctx, user, title, url, contentType)
+ if err != nil {
+ http.Error(w, "Database Error", http.StatusInternalServerError)
+ return
+ }
+
+ source := r.URL.Query().Get("source")
+ if source == "irc" {
+ w.Header().Set("Content-Type", "text/plain")
+ fmt.Fprintf(w, "%d", id)
+ return
+ }
+
+ // HTML Redirect Page
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprintf(w, `
+
+ tumblefish link posted
+
+
+
+
+ Your link has been posted!
+ Redirecting back to %s in 5 seconds...
+
+
+