planetalibre.lua 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. --
  2. -- PlanetaLibre -- An Atom and RSS feed aggregator for Gemini written in Lua.
  3. --
  4. -- Copyright (C) 2023-2024 Ricardo García Jiménez <ricardogj08@riseup.net>
  5. --
  6. -- This program is free software: you can redistribute it and/or modify
  7. -- it under the terms of the GNU General Public License as published by
  8. -- the Free Software Foundation, either version 3 of the License, or
  9. -- (at your option) any later version.
  10. --
  11. -- This program is distributed in the hope that it will be useful,
  12. -- but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. -- GNU General Public License for more details.
  15. --
  16. -- You should have received a copy of the GNU General Public License
  17. -- along with this program. If not, see <https://www.gnu.org/licenses/>.
  18. --
  19. -- Módulos.
  20. local socket = require('socket')
  21. local socket_url = require('socket.url')
  22. local ssl = require('ssl')
  23. local uuid = require('uuid')
  24. require('feedparser')
  25. -- Configuración de la base de datos.
  26. local driver = require('luasql.sqlite3')
  27. local sql_env = assert(driver.sqlite3())
  28. local sql_conn = nil
  29. -- Configuraciones de la aplicación.
  30. local settings = {
  31. gemini = {
  32. scheme = 'gemini',
  33. host = '/',
  34. port = 1965,
  35. },
  36. ssl = {
  37. mode = 'client',
  38. protocol = 'tlsv1_2'
  39. },
  40. mime = {
  41. 'application/xml',
  42. 'text/xml',
  43. 'application/atom+xml',
  44. 'application/rss+xml'
  45. },
  46. timeout = 8,
  47. file = 'feeds.txt',
  48. output = '.',
  49. header = 'header.gemini',
  50. footer = 'footer.gemini',
  51. capsule = 'PlanetaLibre',
  52. domain = 'localhost',
  53. limit = 64,
  54. lang = 'es',
  55. repo = 'https://notabug.org/ricardogj08/planetalibre',
  56. version = '3.0',
  57. license = 'CC-BY-4.0' -- https://spdx.org/licenses
  58. }
  59. -- Ejecuta las migraciones de la base de datos.
  60. local function migrations()
  61. sql_conn = assert(sql_env:connect('database.sqlite'))
  62. -- Crea la tabla de las cápsulas.
  63. assert(sql_conn:execute([[
  64. CREATE TABLE IF NOT EXISTS capsules (
  65. id CHAR(36) NOT NULL,
  66. link TEXT NOT NULL,
  67. name VARCHAR(125) NOT NULL,
  68. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  69. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  70. CONSTRAINT capsules_id_primary PRIMARY KEY(id),
  71. CONSTRAINT capsules_link_unique UNIQUE(link)
  72. )
  73. ]]))
  74. -- Crea la tabla de las publicaciones.
  75. assert(sql_conn:execute([[
  76. CREATE TABLE IF NOT EXISTS posts (
  77. id CHAR(36) NOT NULL,
  78. capsule_id CHAR(36) NOT NULL,
  79. link TEXT NOT NULL,
  80. title VARCHAR(255) NOT NULL,
  81. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  82. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  83. CONSTRAINT posts_id_primary PRIMARY KEY(id),
  84. CONSTRAINT posts_capsule_id_foreign FOREIGN KEY(capsule_id)
  85. REFERENCES capsules(id)
  86. ON DELETE CASCADE
  87. ON UPDATE RESTRICT,
  88. CONSTRAINT posts_link_unique UNIQUE(link)
  89. )
  90. ]]))
  91. end
  92. -- Analiza una URL.
  93. local function urlparser(url)
  94. return socket_url.parse(url, settings.gemini)
  95. end
  96. -- Cliente de peticiones para el protocolo Gemini.
  97. local client = socket.protect(function(url)
  98. ::client::
  99. local response = nil
  100. -- Analiza la URL de la petición.
  101. local parsed_url, err = urlparser(url)
  102. if err then
  103. return response, err
  104. end
  105. -- Comprueba el protocolo de la petición.
  106. if parsed_url.scheme ~= settings.gemini.scheme then
  107. err = 'Invalid url scheme'
  108. return response, err
  109. end
  110. -- Crea un objeto TCP maestro.
  111. local conn = assert(socket.tcp())
  112. -- Crea una función try que cierra el objeto TCP en caso de errores.
  113. local try = socket.newtry(function()
  114. conn:close()
  115. end)
  116. -- Define el tiempo máximo de espera por bloque en modo no seguro.
  117. conn:settimeout(settings.timeout)
  118. -- Realiza la conexión a un host remoto y
  119. -- transforma el objeto TCP maestro a cliente.
  120. try(conn:connect(parsed_url.host, settings.gemini.port))
  121. -- Transforma el objeto TCP cliente para conexiones seguras.
  122. conn = try(ssl.wrap(conn, settings.ssl))
  123. -- Define el tiempo máximo de espera por bloque en modo seguro.
  124. conn:settimeout(settings.timeout)
  125. -- Define el nombre del host al que se intenta conectar.
  126. conn:sni(parsed_url.host)
  127. -- Realiza la conexión segura.
  128. try(conn:dohandshake())
  129. url = socket_url.build(parsed_url)
  130. -- Construye la petición.
  131. local request = string.format('%s\r\n', url)
  132. -- Realiza la petición.
  133. try(conn:send(request))
  134. -- Obtiene el encabezado de la respuesta.
  135. local header = conn:receive('*l')
  136. -- Obtiene el código de estado y la meta de la respuesta.
  137. local status, meta = string.match(header, '(%d+)%s+(.+)')
  138. status = string.sub(status, 1, 1)
  139. local redirect = false
  140. -- Comprueba el código de estado del encabezado.
  141. if status == '2' then
  142. -- Comprueba el mime type de la respuesta.
  143. for _, mime in ipairs(settings.mime) do
  144. -- Obtiene el cuerpo de la respuesta.
  145. if string.find(meta, mime, 1, true) then
  146. response = conn:receive('*a')
  147. break
  148. end
  149. end
  150. if not response then
  151. err = 'Invalid mime type'
  152. end
  153. elseif status == '3' then
  154. redirect = true
  155. elseif status == '4' or status == '5' then
  156. err = meta
  157. elseif status == '6' then
  158. err = 'Client certificate required'
  159. else
  160. err = 'Invalid response from server'
  161. end
  162. -- Cierra el objeto TCP cliente.
  163. conn:close()
  164. -- Soluciona las redirecciones.
  165. if redirect then
  166. url = socket_url.absolute(url, meta)
  167. goto client
  168. end
  169. return response, err
  170. end)
  171. -- Muestra mensajes de éxito.
  172. local function show_success(url)
  173. print(string.format('[success] %s', url))
  174. end
  175. -- Muestra mensajes de errores.
  176. local function show_error(url, err)
  177. print(string.format('[error] %s - %s', url, err))
  178. end
  179. -- Construye una URL.
  180. local function urlbuild(url)
  181. return socket_url.build(urlparser(url))
  182. end
  183. -- Escapa caracteres especiales para la base de datos.
  184. local function escape(str)
  185. return sql_conn:escape(str)
  186. end
  187. -- Genera una tabla del tiempo actual en UTC.
  188. local function current_utc_timetable()
  189. return os.date('!*t')
  190. end
  191. -- Genera un timestamp del tiempo actual en UTC.
  192. local function current_utc_timestamp()
  193. return os.time(current_utc_timetable())
  194. end
  195. -- Convierte un timestamp a un datetime.
  196. local function timestamp_to_datetime(timestamp)
  197. timestamp = timestamp or current_utc_timestamp()
  198. return os.date('%F %T', timestamp)
  199. end
  200. -- Genera un timestamp desde hace un año del tiempo actual en UTC.
  201. local function previous_year_current_utc_timestamp()
  202. return current_utc_timestamp() - 365 * 24 * 60 * 60
  203. end
  204. -- Registra la información de una cápsula y
  205. -- la actualiza si existe en la base de datos.
  206. local function save_capsule(data)
  207. -- Comprueba si existe la cápsula en la base de datos.
  208. local cursor = assert(sql_conn:execute(string.format([[
  209. SELECT id, name
  210. FROM capsules
  211. WHERE link = '%s'
  212. LIMIT 1
  213. ]], escape(data.link))))
  214. local capsule = cursor:fetch({}, 'a')
  215. cursor:close()
  216. -- Registra la información de la cápsula si no existe en la base de datos.
  217. if not capsule then
  218. capsule = { id = uuid() }
  219. assert(sql_conn:execute(string.format([[
  220. INSERT INTO capsules(id, name, link, created_at, updated_at)
  221. VALUES('%s', '%s', '%s', '%s', '%s')
  222. ]], capsule.id,
  223. escape(data.name),
  224. escape(data.link),
  225. timestamp_to_datetime(),
  226. timestamp_to_datetime())))
  227. -- Actualiza el nombre de la cápsula si es diferente en la base de datos.
  228. elseif capsule.name ~= data.name then
  229. assert(sql_conn:execute(string.format([[
  230. UPDATE capsules
  231. SET name = '%s', updated_at = '%s'
  232. WHERE id = '%s'
  233. ]], escape(data.name), timestamp_to_datetime(), capsule.id)))
  234. end
  235. return capsule.id
  236. end
  237. -- Registra la información de una publicación y
  238. -- la actualiza si existe en la base de datos.
  239. local function save_post(data)
  240. -- Comprueba si existe la publicación en la base de datos.
  241. local cursor = assert(sql_conn:execute(string.format([[
  242. SELECT id, title, updated_at
  243. FROM posts
  244. WHERE link = '%s'
  245. LIMIT 1
  246. ]], escape(data.link))))
  247. local post = cursor:fetch({}, 'a')
  248. cursor:close()
  249. -- Registra la información de la publicación si no existe en la base de datos.
  250. if not post then
  251. post = { id = uuid() }
  252. assert(sql_conn:execute(string.format([[
  253. INSERT INTO posts(id, capsule_id, title, link, created_at, updated_at)
  254. VALUES('%s', '%s', '%s', '%s', '%s', '%s')
  255. ]], post.id,
  256. data.capsule_id,
  257. escape(data.title),
  258. escape(data.link),
  259. timestamp_to_datetime(),
  260. data.updated_at)))
  261. -- Actualiza el título y la fecha de actualización de
  262. -- la publicación si es diferente en la base de datos.
  263. elseif post.title ~= data.title or post.updated_at ~= data.updated_at then
  264. assert(sql_conn:execute(string.format([[
  265. UPDATE posts
  266. SET title = '%s', updated_at = '%s'
  267. WHERE id = '%s'
  268. ]], escape(data.title), data.updated_at, post.id)))
  269. end
  270. return post.id
  271. end
  272. -- Escanea un archivo externo con las URLs de los feeds
  273. -- y almacena cada una de sus entradas en la base de datos.
  274. local function scan_feeds()
  275. local file = assert(io.open(settings.file, 'r'))
  276. local timestamp = previous_year_current_utc_timestamp()
  277. for url in file:lines() do
  278. local status, err = pcall(function()
  279. -- Obtiene el cuerpo del feed desde la url del archivo.
  280. local feed = assert(client(url))
  281. -- Analiza el cuerpo del feed.
  282. local parsed_feed = assert(feedparser.parse(feed))
  283. -- Registra y obtiene el ID de la cápsula.
  284. local capsule_id = save_capsule({
  285. name = parsed_feed.feed.title,
  286. link = urlbuild(parsed_feed.feed.link)
  287. })
  288. -- Registra cada entrada de la cápsula con
  289. -- una antigüedad mayor desde hace un año.
  290. for _, entry in ipairs(parsed_feed.entries) do
  291. if entry.updated_parsed > timestamp then
  292. save_post({
  293. title = entry.title,
  294. link = urlbuild(entry.link),
  295. capsule_id = capsule_id,
  296. updated_at = timestamp_to_datetime(entry.updated_parsed)
  297. })
  298. end
  299. end
  300. end)
  301. if status then
  302. show_success(url)
  303. else
  304. show_error(url, err)
  305. end
  306. end
  307. file:close()
  308. end
  309. -- Construye un path.
  310. local function pathbuild(segment)
  311. return string.format('%s/%s', settings.output, segment)
  312. end
  313. -- Construye un link para el sitio web.
  314. local function linkbuild(segment)
  315. local parsed_path = socket_url.parse_path(settings.domain)
  316. local parsed_url = settings.gemini
  317. parsed_url.host = table.remove(parsed_path, 1)
  318. table.insert(parsed_path, segment)
  319. parsed_url.path = '/'..socket_url.build_path(parsed_path)
  320. return socket_url.build(parsed_url)
  321. end
  322. -- Convierte un datetime a un timestamp.
  323. local function datetime_to_timestamp(datetime)
  324. local timetable = nil
  325. if datetime then
  326. local keys = { 'year', 'month', 'day', 'hour', 'min', 'sec' }
  327. local pattern = '(%d+)-(%d+)-(%d+)%s+(%d+):(%d+):(%d+)'
  328. local values = { string.match(datetime, pattern) }
  329. timetable = {}
  330. for index, key in ipairs(keys) do
  331. if not values[index] then
  332. timetable = nil
  333. break
  334. end
  335. timetable[key] = values[index]
  336. end
  337. end
  338. return os.time(timetable or current_utc_timetable())
  339. end
  340. -- Convierte un datetime a un dateatom.
  341. local function datetime_to_dateatom(datetime)
  342. return os.date('%FT%TZ', datetime_to_timestamp(datetime))
  343. end
  344. -- Genera el encabezado del feed de Atom del sitio web.
  345. local function open_atomfeed()
  346. local feedlink = linkbuild('atom.xml')
  347. local homelink = linkbuild('index.gemini')
  348. return string.format([[
  349. <?xml version="1.0" encoding="utf-8"?>
  350. <feed xmlns="http://www.w3.org/2005/Atom" xml:lang="%s">
  351. <title>%s</title>
  352. <link href="%s" rel="self" type="application/atom+xml"/>
  353. <link href="%s" rel="alternate" type="text/gemini"/>
  354. <updated>%s</updated>
  355. <id>%s</id>
  356. <author>
  357. <name>%s</name>
  358. <uri>%s</uri>
  359. </author>
  360. <rights>%s</rights>
  361. <generator uri="%s" version="%s">
  362. PlanetaLibre
  363. </generator>
  364. ]], settings.lang,
  365. settings.capsule,
  366. feedlink,
  367. homelink,
  368. datetime_to_dateatom(),
  369. feedlink,
  370. settings.capsule,
  371. homelink,
  372. settings.license,
  373. settings.repo,
  374. settings.version)
  375. end
  376. -- Genera la etiqueta de cierre del feed de Atom del sitio web.
  377. local function close_atomfeed()
  378. return '</feed>\n'
  379. end
  380. -- Obtiene las publicaciones registradas en la base de datos
  381. -- ordenadas por fecha de actualización.
  382. local function get_posts()
  383. local cursor = assert(sql_conn:execute(string.format([[
  384. SELECT c.name AS capsule_name, c.link AS capsule_link, p.title, p.link, p.updated_at
  385. FROM posts AS p
  386. INNER JOIN capsules AS c
  387. ON p.capsule_id = c.id
  388. ORDER BY p.updated_at DESC
  389. LIMIT %u
  390. ]], settings.limit)))
  391. return function()
  392. return cursor:fetch({}, 'a')
  393. end
  394. end
  395. -- Genera una entrada para el feed de Atom del sitio web.
  396. local function entry_atomfeed(post)
  397. return string.format([[
  398. <entry>
  399. <title>%s</title>
  400. <link href="%s" rel="alternate" type="text/gemini"/>
  401. <id>%s</id>
  402. <updated>%s</updated>
  403. <author>
  404. <name>%s</name>
  405. <uri>%s</uri>
  406. </author>
  407. </entry>
  408. ]], post.title,
  409. post.link,
  410. post.link,
  411. datetime_to_dateatom(post.updated_at),
  412. post.capsule_name,
  413. post.capsule_link)
  414. end
  415. -- Genera un link de Gemini.
  416. local function gemini_link(post)
  417. return string.format('=> %s %s - %s\n', post.link, post.capsule_name, post.title)
  418. end
  419. -- Genera un heading de Gemini.
  420. local function gemini_heading(date)
  421. return string.format('\n### %s\n\n', date)
  422. end
  423. -- Convierte un datetime a un date.
  424. local function datetime_to_date(datetime)
  425. return os.date('%F', datetime_to_timestamp(datetime))
  426. end
  427. -- Genera la cápsula y el feed de Atom del sitio web.
  428. local function generate_capsule()
  429. local homepage = assert(io.open(pathbuild('index.gemini'), 'w+'))
  430. local atomfeed = assert(io.open(pathbuild('atom.xml'), 'w+'))
  431. local header = io.open(settings.header)
  432. -- Incluye el header en la página principal.
  433. if header then
  434. homepage:write(header:read('*a'))
  435. header:close()
  436. end
  437. atomfeed:write(open_atomfeed())
  438. local date = nil
  439. -- Incluye las entradas en la página principal
  440. -- y en el feed de Atom del sitio web.
  441. for post in get_posts() do
  442. local postdate = datetime_to_date(post.updated_at)
  443. -- Agrupa las publicaciones por día.
  444. if date ~= postdate then
  445. date = postdate
  446. homepage:write(gemini_heading(date))
  447. end
  448. homepage:write(gemini_link(post))
  449. atomfeed:write(entry_atomfeed(post))
  450. end
  451. atomfeed:write(close_atomfeed())
  452. atomfeed:close()
  453. local footer = io.open(settings.footer)
  454. -- Incluye el footer en la página principal.
  455. if footer then
  456. homepage:write('\n'..footer:read('*a'))
  457. footer:close()
  458. end
  459. homepage:close()
  460. end
  461. -- Muestra un mensaje de ayuda.
  462. local function help()
  463. print(string.format([[
  464. PlanetaLibre %s - An Atom and RSS feed aggregator for Gemini written in Lua.
  465. Synopsis:
  466. planetalibre [OPTIONS]
  467. Options:
  468. --capsule <STRING> - Capsule name [default: PlanetaLibre].
  469. --domain <STRING> - Capsule domain name [default: localhost].
  470. --file <FILE> - File to read feed URLs from Gemini [default: feeds.txt].
  471. --footer <FILE> - Homepage footer [default: footer.gemini].
  472. --header <FILE> - Homepage header [default: header.gemini].
  473. --lang <STRING> - Capsules language [default: es].
  474. --license <STRING> - Capsule license [default: CC-BY-4.0].
  475. --limit <NUMBER> - Maximum number of posts [default: 64].
  476. --output <PATH> - Output directory [default: .].]], settings.version))
  477. os.exit()
  478. end
  479. -- Opciones de uso en la terminal.
  480. local function usage()
  481. for itr = 1, #arg, 2 do
  482. local option = arg[itr]
  483. local param = arg[itr + 1] or help()
  484. if option == '--capsule' then
  485. settings.capsule = param
  486. elseif option == '--domain' then
  487. settings.domain = param
  488. elseif option == '--file' then
  489. settings.file = param
  490. elseif option == '--footer' then
  491. settings.footer = param
  492. elseif option == '--header' then
  493. settings.header = param
  494. elseif option == '--lang' then
  495. settings.lang = param
  496. elseif option == '--license' then
  497. settings.license = param
  498. elseif option == '--limit' then
  499. settings.limit = param
  500. elseif option == '--output' then
  501. settings.output = param
  502. else
  503. help()
  504. end
  505. end
  506. end
  507. -- Imprime mensajes de actividades.
  508. local function logs(message)
  509. print(string.format('==== %s ====', message))
  510. end
  511. -- Elimina las publicaciones y cápsulas sin publicaciones
  512. -- con una antigüedad de un año en la base de datos.
  513. local function clean_database()
  514. local datetime = timestamp_to_datetime(previous_year_current_utc_timestamp())
  515. assert(sql_conn:execute(string.format([[
  516. DELETE FROM posts
  517. WHERE updated_at < '%s'
  518. ]], datetime)))
  519. assert(sql_conn:execute(string.format([[
  520. DELETE FROM capsules
  521. WHERE id IN(
  522. SELECT c.id
  523. FROM capsules AS c
  524. LEFT JOIN posts AS p
  525. ON c.id = p.capsule_id
  526. WHERE c.updated_at < '%s'
  527. GROUP BY c.id
  528. HAVING COUNT(p.id) = 0
  529. )
  530. ]], datetime)))
  531. end
  532. -- Función principal.
  533. local function main()
  534. usage()
  535. logs('Running database migrations')
  536. migrations()
  537. uuid.seed()
  538. logs('Scanning feed URLs from Gemini')
  539. scan_feeds()
  540. logs('Generating homepage and Atom feed')
  541. generate_capsule()
  542. logs('Deleting old posts and capsules')
  543. clean_database()
  544. sql_conn:close()
  545. sql_env:close()
  546. end
  547. main()