planetalibre.lua 17 KB

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