123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712 |
- --
- -- 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/>.
- --
- -- NOTA: Utilizar solo programación estructurada.
- -- 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())
- -- 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 = '4.0',
- license = 'CC-BY-4.0' -- https://spdx.org/licenses
- }
- -- Ejecuta las migraciones de la base de datos.
- local function migrations()
- -- 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 parse_url(url)
- return socket_url.parse(url, settings.gemini)
- end
- -- Construye una URL.
- local function build_url(url)
- return socket_url.build(parse_url(url))
- end
- -- Construye una URL de PlanetaLibre.
- local function base_url(uri)
- local parsed_path = socket_url.parse_path(settings.domain)
- local parsed_url = settings.gemini
- -- Obtiene el dominio desde la configuración y
- -- mantiene los subdirectorios si se encuentran presentes.
- parsed_url.host = table.remove(parsed_path, 1)
- -- Agrega la uri con los subdirectorios si se encuentran presentes.
- table.insert(parsed_path, uri)
- -- Convierte el array de uris a un string.
- parsed_url.path = '/'..socket_url.build_path(parsed_path)
- return socket_url.build(parsed_url)
- 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 = parse_url(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)
- -- Imprime mensajes de éxito.
- local function log_success(message)
- print(string.format('[success] %s', message))
- end
- -- Imprime mensajes de errores.
- local function log_error(title, description)
- print(string.format('[error] %s - %s', title, description))
- end
- -- Imprime mensajes informativos.
- local function log_info(message)
- print(string.format('==== %s ====', message))
- end
- -- Obtiene un timestamp del tiempo actual en UTC.
- local function now_timestamp()
- return os.time(os.date('!*t'))
- end
- -- Convierte un timestamp a un datetime.
- local function timestamp_to_datetime(timestamp)
- return os.date('%F %T', timestamp)
- end
- -- Obtiene un datetime del tiempo actual en UTC.
- local function now_datetime()
- return timestamp_to_datetime(now_timestamp())
- end
- -- Obtiene un timestamp del tiempo actual desde hace un año en UTC.
- local function now_prev_year_timestamp()
- return now_timestamp() - 365 * 24 * 60 * 60
- end
- -- Obtiene un datetime del tiempo actual desde hace un año en UTC.
- local function now_prev_year_datetime()
- return timestamp_to_datetime(now_prev_year_timestamp())
- end
- -- Convierte un datetime a un timestamp.
- local function datetime_to_timestamp(datetime)
- local pattern = '(%d+)-(%d+)-(%d+)%s+(%d+):(%d+):(%d+)'
- local values = { string.match(datetime, pattern) }
- local keys = { 'year', 'month', 'day', 'hour', 'min', 'sec' }
- local timetable = {}
- -- Genera el timetable del datetime.
- for index, key in ipairs(keys) do
- timetable[key] = values[index]
- end
- return os.time(timetable)
- end
- -- Convierte un datetime a un dateatom.
- local function datetime_to_dateatom(datetime)
- return os.date('%FT%TZ', datetime_to_timestamp(datetime))
- end
- -- Obtiene un dateatom del tiempo actual en UTC.
- local function now_dateatom()
- return datetime_to_dateatom(now_datetime())
- end
- -- Convierte un datetime a un date.
- local function datetime_to_date(datetime)
- return os.date('%F', datetime_to_timestamp(datetime))
- end
- -- Escapa los caracteres especiales de un valor para la base de datos.
- local function escape(value)
- return sql_conn:escape(value)
- end
- -- Consulta la información de una cápsula.
- local function find_capsule_by(field, value)
- local cursor = assert(sql_conn:execute(string.format([[
- SELECT id, name
- FROM capsules
- WHERE %s = '%s'
- LIMIT 1
- ]], escape(field),
- escape(value))))
- local capsule = cursor:fetch({}, 'a')
- cursor:close()
- return capsule
- end
- -- Registra la información de una nueva cápsula.
- local function insert_capsule(data)
- local id = uuid()
- assert(sql_conn:execute(string.format([[
- INSERT INTO capsules(id, name, link, created_at, updated_at)
- VALUES('%s', '%s', '%s', '%s', '%s')
- ]], escape(id),
- escape(data.name),
- escape(data.link),
- escape(now_datetime()),
- escape(now_datetime()))))
- return find_capsule_by('id', id)
- end
- -- Modifica la información de una cápsula.
- local function update_capsule(data, id)
- assert(sql_conn:execute(string.format([[
- UPDATE capsules
- SET name = '%s', updated_at = '%s'
- WHERE id = '%s'
- ]], escape(data.name),
- escape(now_datetime()),
- escape(id))))
- end
- -- Consulta la información de una publicación.
- local function find_post_by(field, value)
- local cursor = assert(sql_conn:execute(string.format([[
- SELECT id, title, updated_at
- FROM posts
- WHERE %s = '%s'
- LIMIT 1
- ]], escape(field),
- escape(value))))
- local post = cursor:fetch({}, 'a')
- cursor:close()
- return post
- end
- -- Registra la información de una nueva publicación.
- local function insert_post(data)
- local 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')
- ]], escape(id),
- escape(data.capsule_id),
- escape(data.title),
- escape(data.link),
- escape(now_datetime()),
- escape(data.updated_at))))
- return find_post_by('id', id)
- end
- -- Modifica la información de una publicación.
- local function update_post(data, id)
- assert(sql_conn:execute(string.format([[
- UPDATE posts
- SET title = '%s', updated_at = '%s'
- WHERE id = '%s'
- ]], escape(data.title),
- escape(data.updated_at),
- escape(id))))
- end
- -- Obtiene todas las publicaciones ordenadas por fecha de modificació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
- ]], escape(settings.limit))))
- return function()
- return cursor:fetch({}, 'a')
- end
- end
- -- Comprueba la información de una cápsula
- -- desde la información del feed.
- local function check_capsule(feed_info)
- -- Establece la información de la cápsula desde el feed.
- local data = { name = feed_info.title, link = build_url(feed_info.link) }
- -- Comprueba si la cápsula se encuentra registrada.
- local capsule = find_capsule_by('link', data.link)
- -- Registra la información de la cápsula si no existe en la base de datos.
- if not capsule then
- capsule = insert_capsule(data)
- -- Actualiza el nombre de la cápsula si es diferente en la base de datos.
- elseif capsule.name ~= data.name then
- update_capsule({ name = data.name }, capsule.id)
- end
- return capsule
- end
- -- Comprueba la información de las publicaciones
- -- desde la información de las entradas del feed.
- local function check_posts(entries, capsule)
- local prev_timestamp = now_prev_year_timestamp()
- -- Registra cada entrada de la cápsula con
- -- una antigüedad mayor desde hace un año.
- for _, entry in ipairs(entries) do
- if entry.updated_parsed > prev_timestamp then
- -- Establece la información de la publicación desde las entradas del feed.
- local data = {
- title = entry.title,
- link = build_url(entry.link),
- capsule_id = capsule.id,
- updated_at = timestamp_to_datetime(entry.updated_parsed)
- }
- -- Comprueba si la publicación se encuentra registrada.
- local post = find_post_by('link', data.link)
- -- Registra la información de la publicación si no existe en la base de datos.
- if not post then
- post = insert_post(data)
- -- Actualiza el título y la fecha de modificació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
- update_post({ title = data.title, updated_at = data.updated_at }, post.id)
- end
- end
- end
- 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'))
- 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))
- -- Comprueba la información de la cápsula.
- local capsule = check_capsule(parsed_feed.feed)
- -- Comprueba la información de las entradas.
- check_posts(parsed_feed.entries, capsule)
- end)
- if status then
- log_success(url)
- else
- log_error(url, err)
- end
- end
- file:close()
- end
- -- Construye un path desde la salida del programa.
- local function build_path(path)
- return string.format('%s/%s', settings.output, path)
- end
- -- Genera el encabezado del feed de Atom de PlanetaLibre.
- local function open_atomfeed()
- local feed_url = base_url('atom.xml')
- local home_url = base_url('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,
- feed_url,
- home_url,
- now_dateatom(),
- feed_url,
- settings.capsule,
- home_url,
- settings.license,
- settings.repo,
- settings.version)
- end
- -- Genera la etiqueta de cierre del feed de Atom de PlanetaLibre.
- local function close_atomfeed()
- return '</feed>\n'
- end
- -- Genera una entrada para el feed de Atom de PlanetaLibre.
- 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 gemtext_link(link, title, description)
- return string.format('=> %s %s - %s\n', link, title, description)
- end
- -- Genera un heading de Gemini.
- local function gemtext_heading(title)
- return string.format('\n### %s\n\n', title)
- end
- -- Genera la cápsula y el feed de Atom de PlanetaLibre.
- local function generate_planetalibre()
- local homepage = assert(io.open(build_path('index.gemini'), 'w+'))
- local atomfeed = assert(io.open(build_path('atom.xml'), 'w+'))
- local header = io.open(settings.header)
- local test_date = nil
- -- Agrega el header en el archivo de la página principal.
- if header then
- homepage:write(header:read('*a'))
- header:close()
- end
- -- Agrega el encabezado en el archivo del feed de atom.
- atomfeed:write(open_atomfeed())
- -- Agrega las publicaciones en el archivo
- -- de la página principal y el feed de atom.
- for post in get_posts() do
- -- Obtiene la fecha de la publicación.
- local post_date = datetime_to_date(post.updated_at)
- -- Agrupa las publicaciones por día.
- if post_date ~= test_date then
- test_date = post_date
- homepage:write(gemtext_heading(test_date))
- end
- -- Agrega las publicaciones en el archivo
- -- de la página principal y el feed de atom.
- homepage:write(gemtext_link(post.link, post.capsule_name, post.title))
- atomfeed:write(entry_atomfeed(post))
- end
- -- Agrega la etiqueta de cierre en el archivo del feed de atom.
- atomfeed:write(close_atomfeed())
- atomfeed:close()
- local footer = io.open(settings.footer)
- -- Agrega el footer en el archivo de la página principal.
- if footer then
- homepage:write('\n'..footer:read('*a'))
- footer:close()
- end
- homepage:close()
- end
- -- Imprime un mensaje de ayuda.
- local function shelp()
- 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 shelp()
- 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
- shelp()
- end
- end
- end
- -- Elimina publicaciones y cápsulas antigüas
- -- desde hace un año en la base de datos.
- local function clean_database()
- local prev_datetime = escape(now_prev_year_datetime())
- assert(sql_conn:execute(string.format([[
- DELETE FROM posts
- WHERE updated_at < '%s'
- ]], prev_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
- )
- ]], prev_datetime)))
- end
- -- Función principal.
- local function main()
- usage()
- log_info('Running database migrations')
- sql_conn = assert(sql_env:connect('database.sqlite'))
- migrations()
- uuid.seed()
- log_info('Scanning feed URLs from Gemini')
- scan_feeds()
- log_info('Generating homepage and Atom feed')
- generate_planetalibre()
- log_info('Deleting old posts and capsules')
- clean_database()
- -- Cierra la conexión con la base de datos.
- sql_conn:close()
- sql_env:close()
- end
- main()
|