123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660 |
- --
- -- PlanetaLibre -- An Atom and RSS feed aggregator for Gemini written in Lua.
- --
- -- Copyright (C) 2023-2024 Ricardo García Jiménez <ricardogj08@riseup.net>
- --
- -- This program is free software: you can redistribute it and/or modify
- -- it under the terms of the GNU General Public License as published by
- -- the Free Software Foundation, either version 3 of the License, or
- -- (at your option) any later version.
- --
- -- This program is distributed in the hope that it will be useful,
- -- but WITHOUT ANY WARRANTY; without even the implied warranty of
- -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- -- GNU General Public License for more details.
- --
- -- You should have received a copy of the GNU General Public License
- -- along with this program. If not, see <https://www.gnu.org/licenses/>.
- --
- -- Módulos.
- local socket = require('socket')
- local socket_url = require('socket.url')
- local ssl = require('ssl')
- local uuid = require('uuid')
- require('feedparser')
- -- Configuración de la base de datos.
- local driver = require('luasql.sqlite3')
- local sql_env = assert(driver.sqlite3())
- local sql_conn = nil
- -- Configuraciones de la aplicación.
- local settings = {
- gemini = {
- scheme = 'gemini',
- host = '/',
- port = 1965,
- },
- ssl = {
- mode = 'client',
- protocol = 'tlsv1_2'
- },
- mime = {
- 'application/xml',
- 'text/xml',
- 'application/atom+xml',
- 'application/rss+xml'
- },
- timeout = 8,
- file = 'feeds.txt',
- output = '.',
- header = 'header.gemini',
- footer = 'footer.gemini',
- capsule = 'PlanetaLibre',
- domain = 'localhost',
- limit = 64,
- lang = 'es',
- repo = 'https://notabug.org/ricardogj08/planetalibre',
- version = '3.0',
- license = 'CC-BY-4.0' -- https://spdx.org/licenses
- }
- -- Ejecuta las migraciones de la base de datos.
- local function migrations()
- sql_conn = assert(sql_env:connect('database.sqlite'))
- -- Crea la tabla de las cápsulas.
- assert(sql_conn:execute([[
- CREATE TABLE IF NOT EXISTS capsules (
- id CHAR(36) NOT NULL,
- link TEXT NOT NULL,
- name VARCHAR(125) NOT NULL,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT capsules_id_primary PRIMARY KEY(id),
- CONSTRAINT capsules_link_unique UNIQUE(link)
- )
- ]]))
- -- Crea la tabla de las publicaciones.
- assert(sql_conn:execute([[
- CREATE TABLE IF NOT EXISTS posts (
- id CHAR(36) NOT NULL,
- capsule_id CHAR(36) NOT NULL,
- link TEXT NOT NULL,
- title VARCHAR(255) NOT NULL,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT posts_id_primary PRIMARY KEY(id),
- CONSTRAINT posts_capsule_id_foreign FOREIGN KEY(capsule_id)
- REFERENCES capsules(id)
- ON DELETE CASCADE
- ON UPDATE RESTRICT,
- CONSTRAINT posts_link_unique UNIQUE(link)
- )
- ]]))
- end
- -- Analiza una URL.
- local function urlparser(url)
- return socket_url.parse(url, settings.gemini)
- end
- -- Cliente de peticiones para el protocolo Gemini.
- local client = socket.protect(function(url)
- ::client::
- local response = nil
- -- Analiza la URL de la petición.
- local parsed_url, err = urlparser(url)
- if err then
- return response, err
- end
- -- Comprueba el protocolo de la petición.
- if parsed_url.scheme ~= settings.gemini.scheme then
- err = 'Invalid url scheme'
- return response, err
- end
- -- Crea un objeto TCP maestro.
- local conn = assert(socket.tcp())
- -- Crea una función try que cierra el objeto TCP en caso de errores.
- local try = socket.newtry(function()
- conn:close()
- end)
- -- Define el tiempo máximo de espera por bloque en modo no seguro.
- conn:settimeout(settings.timeout)
- -- Realiza la conexión a un host remoto y
- -- transforma el objeto TCP maestro a cliente.
- try(conn:connect(parsed_url.host, settings.gemini.port))
- -- Transforma el objeto TCP cliente para conexiones seguras.
- conn = try(ssl.wrap(conn, settings.ssl))
- -- Define el tiempo máximo de espera por bloque en modo seguro.
- conn:settimeout(settings.timeout)
- -- Define el nombre del host al que se intenta conectar.
- conn:sni(parsed_url.host)
- -- Realiza la conexión segura.
- try(conn:dohandshake())
- url = socket_url.build(parsed_url)
- -- Construye la petición.
- local request = string.format('%s\r\n', url)
- -- Realiza la petición.
- try(conn:send(request))
- -- Obtiene el encabezado de la respuesta.
- local header = conn:receive('*l')
- -- Obtiene el código de estado y la meta de la respuesta.
- local status, meta = string.match(header, '(%d+)%s+(.+)')
- status = string.sub(status, 1, 1)
- local redirect = false
- -- Comprueba el código de estado del encabezado.
- if status == '2' then
- -- Comprueba el mime type de la respuesta.
- for _, mime in ipairs(settings.mime) do
- -- Obtiene el cuerpo de la respuesta.
- if string.find(meta, mime, 1, true) then
- response = conn:receive('*a')
- break
- end
- end
- if not response then
- err = 'Invalid mime type'
- end
- elseif status == '3' then
- redirect = true
- elseif status == '4' or status == '5' then
- err = meta
- elseif status == '6' then
- err = 'Client certificate required'
- else
- err = 'Invalid response from server'
- end
- -- Cierra el objeto TCP cliente.
- conn:close()
- -- Soluciona las redirecciones.
- if redirect then
- url = socket_url.absolute(url, meta)
- goto client
- end
- return response, err
- end)
- -- Muestra mensajes de éxito.
- local function show_success(url)
- print(string.format('[success] %s', url))
- end
- -- Muestra mensajes de errores.
- local function show_error(url, err)
- print(string.format('[error] %s - %s', url, err))
- end
- -- Construye una URL.
- local function urlbuild(url)
- return socket_url.build(urlparser(url))
- end
- -- Escapa caracteres especiales para la base de datos.
- local function escape(str)
- return sql_conn:escape(str)
- end
- -- Genera una tabla del tiempo actual en UTC.
- local function current_utc_timetable()
- return os.date('!*t')
- end
- -- Genera un timestamp del tiempo actual en UTC.
- local function current_utc_timestamp()
- return os.time(current_utc_timetable())
- end
- -- Convierte un timestamp a un datetime.
- local function timestamp_to_datetime(timestamp)
- timestamp = timestamp or current_utc_timestamp()
- return os.date('%F %T', timestamp)
- end
- -- Genera un timestamp desde hace un año del tiempo actual en UTC.
- local function previous_year_current_utc_timestamp()
- return current_utc_timestamp() - 365 * 24 * 60 * 60
- end
- -- Registra la información de una cápsula y
- -- la actualiza si existe en la base de datos.
- local function save_capsule(data)
- -- Comprueba si existe la cápsula en la base de datos.
- local cursor = assert(sql_conn:execute(string.format([[
- SELECT id, name
- FROM capsules
- WHERE link = '%s'
- LIMIT 1
- ]], escape(data.link))))
- local capsule = cursor:fetch({}, 'a')
- cursor:close()
- -- Registra la información de la cápsula si no existe en la base de datos.
- if not capsule then
- capsule = { id = uuid() }
- assert(sql_conn:execute(string.format([[
- INSERT INTO capsules(id, name, link, created_at, updated_at)
- VALUES('%s', '%s', '%s', '%s', '%s')
- ]], capsule.id,
- escape(data.name),
- escape(data.link),
- timestamp_to_datetime(),
- timestamp_to_datetime())))
- -- Actualiza el nombre de la cápsula si es diferente en la base de datos.
- elseif capsule.name ~= data.name then
- assert(sql_conn:execute(string.format([[
- UPDATE capsules
- SET name = '%s', updated_at = '%s'
- WHERE id = '%s'
- ]], escape(data.name), timestamp_to_datetime(), capsule.id)))
- end
- return capsule.id
- end
- -- Registra la información de una publicación y
- -- la actualiza si existe en la base de datos.
- local function save_post(data)
- -- Comprueba si existe la publicación en la base de datos.
- local cursor = assert(sql_conn:execute(string.format([[
- SELECT id, title, updated_at
- FROM posts
- WHERE link = '%s'
- LIMIT 1
- ]], escape(data.link))))
- local post = cursor:fetch({}, 'a')
- cursor:close()
- -- Registra la información de la publicación si no existe en la base de datos.
- if not post then
- post = { id = uuid() }
- assert(sql_conn:execute(string.format([[
- INSERT INTO posts(id, capsule_id, title, link, created_at, updated_at)
- VALUES('%s', '%s', '%s', '%s', '%s', '%s')
- ]], post.id,
- data.capsule_id,
- escape(data.title),
- escape(data.link),
- timestamp_to_datetime(),
- data.updated_at)))
- -- Actualiza el título y la fecha de actualización de
- -- la publicación si es diferente en la base de datos.
- elseif post.title ~= data.title or post.updated_at ~= data.updated_at then
- assert(sql_conn:execute(string.format([[
- UPDATE posts
- SET title = '%s', updated_at = '%s'
- WHERE id = '%s'
- ]], escape(data.title), data.updated_at, post.id)))
- end
- return post.id
- end
- -- Escanea un archivo externo con las URLs de los feeds
- -- y almacena cada una de sus entradas en la base de datos.
- local function scan_feeds()
- local file = assert(io.open(settings.file, 'r'))
- local timestamp = previous_year_current_utc_timestamp()
- for url in file:lines() do
- local status, err = pcall(function()
- -- Obtiene el cuerpo del feed desde la url del archivo.
- local feed = assert(client(url))
- -- Analiza el cuerpo del feed.
- local parsed_feed = assert(feedparser.parse(feed))
- -- Registra y obtiene el ID de la cápsula.
- local capsule_id = save_capsule({
- name = parsed_feed.feed.title,
- link = urlbuild(parsed_feed.feed.link)
- })
- -- Registra cada entrada de la cápsula con
- -- una antigüedad mayor desde hace un año.
- for _, entry in ipairs(parsed_feed.entries) do
- if entry.updated_parsed > timestamp then
- save_post({
- title = entry.title,
- link = urlbuild(entry.link),
- capsule_id = capsule_id,
- updated_at = timestamp_to_datetime(entry.updated_parsed)
- })
- end
- end
- end)
- if status then
- show_success(url)
- else
- show_error(url, err)
- end
- end
- file:close()
- end
- -- Construye un path.
- local function pathbuild(segment)
- return string.format('%s/%s', settings.output, segment)
- end
- -- Construye un link para el sitio web.
- local function linkbuild(segment)
- local parsed_path = socket_url.parse_path(settings.domain)
- local parsed_url = settings.gemini
- parsed_url.host = table.remove(parsed_path, 1)
- table.insert(parsed_path, segment)
- parsed_url.path = '/'..socket_url.build_path(parsed_path)
- return socket_url.build(parsed_url)
- end
- -- Convierte un datetime a un timestamp.
- local function datetime_to_timestamp(datetime)
- local timetable = nil
- if datetime then
- local keys = { 'year', 'month', 'day', 'hour', 'min', 'sec' }
- local pattern = '(%d+)-(%d+)-(%d+)%s+(%d+):(%d+):(%d+)'
- local values = { string.match(datetime, pattern) }
- timetable = {}
- for index, key in ipairs(keys) do
- if not values[index] then
- timetable = nil
- break
- end
- timetable[key] = values[index]
- end
- end
- return os.time(timetable or current_utc_timetable())
- end
- -- Convierte un datetime a un dateatom.
- local function datetime_to_dateatom(datetime)
- return os.date('%FT%TZ', datetime_to_timestamp(datetime))
- end
- -- Genera el encabezado del feed de Atom del sitio web.
- local function open_atomfeed()
- local feedlink = linkbuild('atom.xml')
- local homelink = linkbuild('index.gemini')
- return string.format([[
- <?xml version="1.0" encoding="utf-8"?>
- <feed xmlns="http://www.w3.org/2005/Atom" xml:lang="%s">
- <title>%s</title>
- <link href="%s" rel="self" type="application/atom+xml"/>
- <link href="%s" rel="alternate" type="text/gemini"/>
- <updated>%s</updated>
- <id>%s</id>
- <author>
- <name>%s</name>
- <uri>%s</uri>
- </author>
- <rights>%s</rights>
- <generator uri="%s" version="%s">
- PlanetaLibre
- </generator>
- ]], settings.lang,
- settings.capsule,
- feedlink,
- homelink,
- datetime_to_dateatom(),
- feedlink,
- settings.capsule,
- homelink,
- settings.license,
- settings.repo,
- settings.version)
- end
- -- Genera la etiqueta de cierre del feed de Atom del sitio web.
- local function close_atomfeed()
- return '</feed>\n'
- end
- -- Obtiene las publicaciones registradas en la base de datos
- -- ordenadas por fecha de actualización.
- local function get_posts()
- local cursor = assert(sql_conn:execute(string.format([[
- SELECT c.name AS capsule_name, c.link AS capsule_link, p.title, p.link, p.updated_at
- FROM posts AS p
- INNER JOIN capsules AS c
- ON p.capsule_id = c.id
- ORDER BY p.updated_at DESC
- LIMIT %u
- ]], settings.limit)))
- return function()
- return cursor:fetch({}, 'a')
- end
- end
- -- Genera una entrada para el feed de Atom del sitio web.
- local function entry_atomfeed(post)
- return string.format([[
- <entry>
- <title>%s</title>
- <link href="%s" rel="alternate" type="text/gemini"/>
- <id>%s</id>
- <updated>%s</updated>
- <author>
- <name>%s</name>
- <uri>%s</uri>
- </author>
- </entry>
- ]], post.title,
- post.link,
- post.link,
- datetime_to_dateatom(post.updated_at),
- post.capsule_name,
- post.capsule_link)
- end
- -- Genera un link de Gemini.
- local function gemini_link(post)
- return string.format('=> %s %s - %s\n', post.link, post.capsule_name, post.title)
- end
- -- Genera un heading de Gemini.
- local function gemini_heading(date)
- return string.format('\n### %s\n\n', date)
- end
- -- Convierte un datetime a un date.
- local function datetime_to_date(datetime)
- return os.date('%F', datetime_to_timestamp(datetime))
- end
- -- Genera la cápsula y el feed de Atom del sitio web.
- local function generate_capsule()
- local homepage = assert(io.open(pathbuild('index.gemini'), 'w+'))
- local atomfeed = assert(io.open(pathbuild('atom.xml'), 'w+'))
- local header = io.open(settings.header)
- -- Incluye el header en la página principal.
- if header then
- homepage:write(header:read('*a'))
- header:close()
- end
- atomfeed:write(open_atomfeed())
- local date = nil
- -- Incluye las entradas en la página principal
- -- y en el feed de Atom del sitio web.
- for post in get_posts() do
- local postdate = datetime_to_date(post.updated_at)
- -- Agrupa las publicaciones por día.
- if date ~= postdate then
- date = postdate
- homepage:write(gemini_heading(date))
- end
- homepage:write(gemini_link(post))
- atomfeed:write(entry_atomfeed(post))
- end
- atomfeed:write(close_atomfeed())
- atomfeed:close()
- local footer = io.open(settings.footer)
- -- Incluye el footer en la página principal.
- if footer then
- homepage:write('\n'..footer:read('*a'))
- footer:close()
- end
- homepage:close()
- end
- -- Muestra un mensaje de ayuda.
- local function help()
- print(string.format([[
- PlanetaLibre %s - An Atom and RSS feed aggregator for Gemini written in Lua.
- Synopsis:
- planetalibre [OPTIONS]
- Options:
- --capsule <STRING> - Capsule name [default: PlanetaLibre].
- --domain <STRING> - Capsule domain name [default: localhost].
- --file <FILE> - File to read feed URLs from Gemini [default: feeds.txt].
- --footer <FILE> - Homepage footer [default: footer.gemini].
- --header <FILE> - Homepage header [default: header.gemini].
- --lang <STRING> - Capsules language [default: es].
- --license <STRING> - Capsule license [default: CC-BY-4.0].
- --limit <NUMBER> - Maximum number of posts [default: 64].
- --output <PATH> - Output directory [default: .].]], settings.version))
- os.exit()
- end
- -- Opciones de uso en la terminal.
- local function usage()
- for itr = 1, #arg, 2 do
- local option = arg[itr]
- local param = arg[itr + 1] or help()
- if option == '--capsule' then
- settings.capsule = param
- elseif option == '--domain' then
- settings.domain = param
- elseif option == '--file' then
- settings.file = param
- elseif option == '--footer' then
- settings.footer = param
- elseif option == '--header' then
- settings.header = param
- elseif option == '--lang' then
- settings.lang = param
- elseif option == '--license' then
- settings.license = param
- elseif option == '--limit' then
- settings.limit = param
- elseif option == '--output' then
- settings.output = param
- else
- help()
- end
- end
- end
- -- Imprime mensajes de actividades.
- local function logs(message)
- print(string.format('==== %s ====', message))
- end
- -- Elimina las publicaciones y cápsulas sin publicaciones
- -- con una antigüedad de un año en la base de datos.
- local function clean_database()
- local datetime = timestamp_to_datetime(previous_year_current_utc_timestamp())
- assert(sql_conn:execute(string.format([[
- DELETE FROM posts
- WHERE updated_at < '%s'
- ]], datetime)))
- assert(sql_conn:execute(string.format([[
- DELETE FROM capsules
- WHERE id IN(
- SELECT c.id
- FROM capsules AS c
- LEFT JOIN posts AS p
- ON c.id = p.capsule_id
- WHERE c.updated_at < '%s'
- GROUP BY c.id
- HAVING COUNT(p.id) = 0
- )
- ]], datetime)))
- end
- -- Función principal.
- local function main()
- usage()
- logs('Running database migrations')
- migrations()
- uuid.seed()
- logs('Scanning feed URLs from Gemini')
- scan_feeds()
- logs('Generating homepage and Atom feed')
- generate_capsule()
- logs('Deleting old posts and capsules')
- clean_database()
- sql_conn:close()
- sql_env:close()
- end
- main()
|