diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..5d126348 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..6c7dde5c --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,28 @@ +module.exports = { + root: true, + env: { + browser: true, + node: true + }, + parserOptions: { + parser: 'babel-eslint' + }, + extends: [ + '@nuxtjs', + // 'plugin:nuxt/recommended', + // 'plugin:prettier/recommended', + // 'prettier', + // 'prettier/vue' + ], + plugins: [ + 'prettier' + ], + // add your custom rules here + rules: { + 'nuxt/no-cjs-in-config': 'off', + 'camelcase': 'off', + 'no-console': 'off', + 'arrow-parens': 'off', + 'import/order': 'off' + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b9a6eb25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,89 @@ +# Created by .ignore support plugin (hsz.mobi) +### Gancio production configuration +config.js + +### Node template +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +uploads + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# Nuxt generate +dist + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless + +# IDE +.idea + +# Service worker +sw.* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..b2095be8 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "semi": false, + "singleQuote": true +} diff --git a/.sequelizerc b/.sequelizerc new file mode 100644 index 00000000..31b6598f --- /dev/null +++ b/.sequelizerc @@ -0,0 +1,7 @@ +const path = require('path') + +module.exports = { + 'config': path.resolve('config.js'), + 'migrations-path': path.resolve('server', 'migrations'), + 'models-path': path.resolve('server', 'api', 'models') +} diff --git a/.vscode/vscode-kanban.json b/.vscode/vscode-kanban.json new file mode 100644 index 00000000..a487fe65 --- /dev/null +++ b/.vscode/vscode-kanban.json @@ -0,0 +1,331 @@ +{ + "done": [ + { + "assignedTo": { + "name": "lesion" + }, + "category": "feature", + "creation_time": "2019-04-23T19:47:35.777Z", + "id": "5", + "prio": 3, + "references": [], + "title": "server side auth" + }, + { + "assignedTo": { + "name": "lesion" + }, + "category": "feature", + "creation_time": "2019-04-23T19:50:00.973Z", + "description": { + "content": "- export page ok\n- usare un'altra api per retrieve di eventi (perche' devo mostrarli tutti, non solo quelli del mese corrente)\n- non devo fare il load degli eventi nel nuxtServerInit (o dentro il layout normale oppure nelle pagine, tipo nella home)", + "mime": "text/markdown" + }, + "details": { + "content": "- export page ok\n- usare un'altra api per retrieve di eventi (perche' devo mostrarli tutti, non solo quelli del mese corrente)\n- non devo fare il load degli eventi nel nuxtServerInit (o dentro il layout normale oppure nelle pagine, tipo nella home)\n", + "mime": "text/markdown" + }, + "id": "7", + "prio": 1, + "references": [], + "title": "export page", + "type": "bug" + }, + { + "assignedTo": { + "name": "lesion" + }, + "category": "feature", + "creation_time": "2019-04-23T19:55:59.993Z", + "id": "10", + "prio": 1, + "references": [], + "title": "gestione errori form aggiungi evento", + "type": "bug" + }, + { + "assignedTo": { + "name": "lesion" + }, + "category": "feature", + "creation_time": "2019-04-23T19:56:46.263Z", + "id": "11", + "prio": 1, + "references": [], + "title": "get comments / media from mastodon" + }, + { + "assignedTo": { + "name": "lesion" + }, + "category": "feature", + "creation_time": "2019-04-23T19:46:46.332Z", + "id": "3", + "prio": 0, + "references": [], + "title": "export lista" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-04-30T12:46:42.208Z", + "id": "16", + "references": [], + "title": "riesco a rimuovere bootstrap-vue e usare solo element ?" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-04-28T09:25:50.701Z", + "id": "13", + "references": [], + "title": "test altra visualizzazione" + } + ], + "in-progress": [ + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-04-30T22:00:29.237Z", + "id": "17", + "references": [], + "title": "porcoddio la config arriva anche al client ovviamente, devo separare!" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-04-30T12:31:20.056Z", + "id": "15", + "references": [], + "title": "test deploy" + } + ], + "testing": [], + "todo": [ + { + "assignedTo": { + "name": "lesion" + }, + "category": "feature", + "creation_time": "2019-04-23T19:50:55.458Z", + "id": "8", + "prio": 1, + "references": [], + "title": "rivedere ux / messaggi utente", + "type": "bug" + }, + { + "assignedTo": { + "name": "lesion" + }, + "category": "feature", + "creation_time": "2019-04-23T19:45:27.613Z", + "description": { + "content": "probabilmente devo far diventare il campo senza timezone", + "mime": "text/markdown" + }, + "id": "2", + "prio": 0, + "references": [], + "title": "check date timezone", + "type": "bug" + }, + { + "assignedTo": { + "name": "lesion" + }, + "category": "feature", + "creation_time": "2019-04-23T19:48:54.407Z", + "description": { + "content": "probabilmente lato client dovrei aggiungere una classe css al body per capire se js e' attivo o meno e poi lavorare di css", + "mime": "text/markdown" + }, + "id": "6", + "prio": 0, + "references": [], + "title": "risolvere le modali quando il js e' disabilitato", + "type": "bug" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-06-01T21:00:22.155Z", + "id": "28", + "references": [], + "title": "activitypub stream" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-05-27T15:42:35.467Z", + "id": "21", + "references": [], + "title": "all'admin deve mostrare un badge se ci sono pending operation" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-05-27T15:16:22.839Z", + "id": "20", + "references": [], + "title": "check password reset" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-06-01T21:23:46.941Z", + "id": "30", + "references": [], + "title": "choose listening port" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-05-27T15:45:39.093Z", + "id": "23", + "references": [], + "title": "colori te prego!" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-05-27T20:42:22.581Z", + "id": "24", + "references": [], + "title": "copy to clipboard" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-05-29T13:08:20.887Z", + "id": "25", + "references": [], + "title": "creazione script di backup" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-06-01T21:00:07.431Z", + "id": "27", + "references": [], + "title": "eventi ricorrenti" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-05-27T15:45:18.012Z", + "id": "22", + "references": [], + "title": "filtri per luogo!" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-04-29T10:01:01.632Z", + "id": "14", + "references": [], + "title": "gestione errori quando non c'e' un evento" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-05-02T16:15:07.106Z", + "id": "19", + "references": [], + "title": "modifica eventi multigiorno" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-05-02T11:50:28.476Z", + "id": "18", + "references": [], + "title": "notifiche email / iscrizione / evento da confermare" + }, + { + "assignedTo": { + "name": "lesion" + }, + "category": "feature", + "creation_time": "2019-04-23T19:44:56.705Z", + "id": "1", + "references": [], + "title": "popup sul calendario" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-04-27T19:44:33.769Z", + "id": "12", + "references": [], + "title": "rifare il calendario o solo il popup" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-06-01T21:15:42.190Z", + "id": "29", + "references": [], + "title": "settings di istanza (default filter, eg, eventi ricorrenti)" + }, + { + "assignedTo": { + "name": "lesion" + }, + "category": "feature", + "creation_time": "2019-04-23T19:47:10.704Z", + "id": "4", + "prio": 0, + "references": [], + "title": "traduzione in inglese" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-05-29T13:10:04.463Z", + "id": "26", + "references": [], + "title": "v-calendar colori e eventi multidays..." + }, + { + "assignedTo": { + "name": "lesion" + }, + "category": "feature", + "creation_time": "2019-04-23T19:51:05.917Z", + "id": "9", + "prio": -1, + "references": [], + "title": "documentare sorgenti", + "type": "bug" + }, + { + "assignedTo": { + "name": "lesion" + }, + "creation_time": "2019-06-05T20:39:59.287Z", + "id": "31", + "references": [], + "title": "scroll h su mobile" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..8c8b463f --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +## gancio +### event manager for radical communities + +> :warning: Gancio is under heavy development, +> if something is not working as expected, it's expected :D + + +## Install +You will need `npm` or `yarn` installed in your system. + + +``` bash +# clone this repo +git clone https://git.lattuga.net/cisti/gancio.git +cd gancio + +# install dependencies +yarn install + +# edit configuration +cp config.example.js config.js + +# - migrate/create test sqlite db +yarn migrate:dev + +# testing with sqlite db +yarn dev + +# - migrate/create production db +yarn migrate + +# build for production and launch server +yarn build +yarn start + +``` + +##### nginx setup +https://nuxtjs.org/faq/nginx-proxy + +For detailed explanation on how things work, checkout [Nuxt.js docs](https://nuxtjs.org). + + +## Hacking + +``` bash +$ yarn dev + +``` \ No newline at end of file diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 00000000..34766f93 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,7 @@ +# ASSETS + +**This directory is not required, you can delete it if you don't want to use it.** + +This directory contains your un-compiled assets such as LESS, SASS, or JavaScript. + +More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked). diff --git a/assets/style.less b/assets/style.less new file mode 100644 index 00000000..09f8e442 --- /dev/null +++ b/assets/style.less @@ -0,0 +1,73 @@ +@background: #222C32; +@success: #c7ffbc; +// @info + +html, body { + margin: 0px; + background-color: @background !important; + width: 100%; + overflow-x: hidden; + box-sizing: border-box; + font-family: BlinkMacSystemFont,-apple-system,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,Helvetica,Arial,sans-serif !important; +} + +* { + box-sizing: border-box; +} + +.el-form-item { + margin-bottom: 5px; +} +.el-divider__text { + background-color: @background; + color: white; + border-radius: 5px; +} + +.el-card { + max-width: 600px; + margin: 30px auto; +} + +.el-dialog { + margin-top: 0px !important; + border-radius: 0px; + width: 100%; + + .el-dialog__body { + word-break: break-word; + } +} + +.el-select-dropdown { + max-width: 100%; + left: 0px; +} + +.page-enter-active, .page-leave-active { + transition: opacity .2s, transform .3s; +} +.page-enter, .page-leave-active { + transition: opacity .3s, transform .2s; + opacity: 0; + // transform: translateY(30px); +} + +pre { + font-family: BlinkMacSystemFont,-apple-system,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,Helvetica,Arial,sans-serif !important; + margin-bottom: 0px; + white-space: pre-line; + font-family: unset; +} + +.el-popover { + word-break: normal; +} + +@media only screen and (min-width: 768px) { + .el-dialog { + margin-top: 10vh !important; + width: 700px; + border-radius: 3px; + } +} diff --git a/components/Calendar.vue b/components/Calendar.vue new file mode 100644 index 00000000..e42e27f4 --- /dev/null +++ b/components/Calendar.vue @@ -0,0 +1,95 @@ + + + + diff --git a/components/Event.vue b/components/Event.vue new file mode 100644 index 00000000..c65600ef --- /dev/null +++ b/components/Event.vue @@ -0,0 +1,138 @@ + + + diff --git a/components/Home.vue b/components/Home.vue new file mode 100644 index 00000000..f9c386cf --- /dev/null +++ b/components/Home.vue @@ -0,0 +1,57 @@ + + + + diff --git a/components/List.vue b/components/List.vue new file mode 100644 index 00000000..7a1706cf --- /dev/null +++ b/components/List.vue @@ -0,0 +1,82 @@ + + + diff --git a/components/Logo.vue b/components/Logo.vue new file mode 100644 index 00000000..6c728541 --- /dev/null +++ b/components/Logo.vue @@ -0,0 +1,79 @@ + + + diff --git a/components/Nav.vue b/components/Nav.vue new file mode 100644 index 00000000..4c50afc4 --- /dev/null +++ b/components/Nav.vue @@ -0,0 +1,60 @@ + + + + diff --git a/components/README.md b/components/README.md new file mode 100644 index 00000000..a079f106 --- /dev/null +++ b/components/README.md @@ -0,0 +1,7 @@ +# COMPONENTS + +**This directory is not required, you can delete it if you don't want to use it.** + +The components directory contains your Vue.js Components. + +_Nuxt.js doesn't supercharge these components._ diff --git a/components/Search.vue b/components/Search.vue new file mode 100644 index 00000000..5080cbd4 --- /dev/null +++ b/components/Search.vue @@ -0,0 +1,64 @@ + + + diff --git a/config.example.js b/config.example.js new file mode 100644 index 00000000..38ba8953 --- /dev/null +++ b/config.example.js @@ -0,0 +1,58 @@ +/** + * GANCIO CONFIGURATION + */ +const env = process.env.NODE_ENV || 'development' + +/** + * Database configuration + * `development` configuration is enabled running `yarn dev` + * while `production` with `yarn start` + * ref: http://docs.sequelizejs.com/class/lib/sequelize.js~Sequelize.html#instance-constructor-constructor + */ +const DB_CONF = { + development: { + storage: __dirname + '/db.sqlite', + dialect: 'sqlite', + }, + production: { + username: '', + password: '', + database: 'gancio', + host: 'localhost', + dialect: 'postgres', + logging: false + }, +} + +const SECRET_CONF = { + // where events/users confirmation email are sent + admin: 'gancio@example.com', + + db: DB_CONF[env], + + // jwt salt secret (generate it randomly) + secret: '', + + // smtp account to send email + smtp: { + host: process.env.SMTP_HOST || 'mail.example.com', + secure: true, + auth: { + user: process.env.SMTP_USER || 'gancio@example.com', + pass: process.env.SMTP_PASS || '' + } + }, +} + +/** + * Main Gancio configuration + */ +const SHARED_CONF = { + locale: 'it', + title: 'GANCIO', + description: 'A calendar for radical communities', + baseurl: '' || 'http://localhost:3000', + env +} + +module.exports = { SHARED_CONF, SECRET_CONF, ...SECRET_CONF.db } diff --git a/layouts/README.md b/layouts/README.md new file mode 100644 index 00000000..cad1ad57 --- /dev/null +++ b/layouts/README.md @@ -0,0 +1,7 @@ +# LAYOUTS + +**This directory is not required, you can delete it if you don't want to use it.** + +This directory contains your Application Layouts. + +More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/views#layouts). diff --git a/layouts/default.vue b/layouts/default.vue new file mode 100644 index 00000000..1a2ea589 --- /dev/null +++ b/layouts/default.vue @@ -0,0 +1,6 @@ + + diff --git a/layouts/iframe.vue b/layouts/iframe.vue new file mode 100644 index 00000000..2cc8469e --- /dev/null +++ b/layouts/iframe.vue @@ -0,0 +1,3 @@ + diff --git a/locales/email/en.json b/locales/email/en.json new file mode 100644 index 00000000..c22030f9 --- /dev/null +++ b/locales/email/en.json @@ -0,0 +1,6 @@ +{ + "registration_email": "registration_email", + "recover_email": "recover_email", + "press here": "press here", + "register.request": "register.request" +} \ No newline at end of file diff --git a/locales/email/es.json b/locales/email/es.json new file mode 100644 index 00000000..c22030f9 --- /dev/null +++ b/locales/email/es.json @@ -0,0 +1,6 @@ +{ + "registration_email": "registration_email", + "recover_email": "recover_email", + "press here": "press here", + "register.request": "register.request" +} \ No newline at end of file diff --git a/locales/email/it.json b/locales/email/it.json new file mode 100644 index 00000000..c22030f9 --- /dev/null +++ b/locales/email/it.json @@ -0,0 +1,6 @@ +{ + "registration_email": "registration_email", + "recover_email": "recover_email", + "press here": "press here", + "register.request": "register.request" +} \ No newline at end of file diff --git a/locales/email/zh.json b/locales/email/zh.json new file mode 100644 index 00000000..c22030f9 --- /dev/null +++ b/locales/email/zh.json @@ -0,0 +1,6 @@ +{ + "registration_email": "registration_email", + "recover_email": "recover_email", + "press here": "press here", + "register.request": "register.request" +} \ No newline at end of file diff --git a/locales/en.js b/locales/en.js new file mode 100644 index 00000000..f01ae3b9 --- /dev/null +++ b/locales/en.js @@ -0,0 +1,3 @@ +{ + "registration_email": "registration_email" +} \ No newline at end of file diff --git a/locales/es.js b/locales/es.js new file mode 100644 index 00000000..f01ae3b9 --- /dev/null +++ b/locales/es.js @@ -0,0 +1,3 @@ +{ + "registration_email": "registration_email" +} \ No newline at end of file diff --git a/locales/it.js b/locales/it.js new file mode 100644 index 00000000..b4b881e6 --- /dev/null +++ b/locales/it.js @@ -0,0 +1,139 @@ +const it = { + common: { + add_event: 'Nuovo evento', + next: 'Continua', + export: 'Esporta', + send: 'Invia', + where: 'Dove', + address: 'Indirizzo', + when: 'Quando', + what: 'Cosa', + media: 'Media', + login: 'Entra', + email: 'Email', + password: 'Password', + register: 'Registrati', + description: 'Descrizione', + remove: 'Elimina', + hide: 'Nascondi', + search: 'Cerca', + edit: 'Modifica', + info: 'Info', + confirm: 'Conferma', + admin: 'Amministra', + users: 'Utenti', + events: 'Eventi', + places: 'Luoghi', + settings: 'Opzioni', + actions: 'Azioni', + deactivate: 'Disattiva', + remove_admin: 'Rimuovi Admin', + activate: 'Attiva', + save: 'Salva', + preview: 'Anteprima', + logout: 'Esci', + share: 'Esporta', + name: 'Nome', + associate: 'Associa', + edit_event: 'Modifica evento', + related: 'Memoria storica', + add: 'Aggiungi', + logout_ok: 'Uscita correttamente', + copy: 'Copia', + recover_password: 'Recupera password', + new_password: 'Nuova password' + }, + + login: { + description: `Entrando puoi pubblicare nuovi eventi.`, + check_email: 'Controlla la tua posta (anche lo spam)', + not_registered: 'Non sei registrata?', + forgot_password: 'Dimenticato la password?', + error: 'Errore: ', + insert_email: 'Inserisci la mail', + ok: 'Tutto rego' + }, + + export: { + intro: `Contrariamente alle piattaforme del capitalismo, che fanno di tutto per tenere + i dati e gli utenti al loro interno, crediamo che le informazioni, come le persone, + debbano essere libere. Per questo puoi rimanere aggiornata sugli eventi che vuoi, come meglio credi, senza necessariamente passare da questo sito.`, + email_description: `Puoi ricevere via mail gli eventi che ti interessano.`, + insert_your_address: 'Indirizzo email', + feed_description: `Per seguire gli aggiornamenti da computer o smartphone senza la necessità di aprire periodicamente il sito, il metodo consigliato è quello dei Feed RSS.

+ +

Con i feed rss utilizzi un'apposita applicazione per ricevere aggiornamenti dai siti che più ti interessano. È un buon metodo per seguire anche molti siti in modo molto rapido, senza necessità di creare un account o altre complicazioni.

+ +
  • Se hai Android, ti consigliamo Flym o Feeder
  • +
  • Per iPhone/iPad puoi usare Feed4U
  • +
  • Per il computer fisso/portatile consigliamo Feedbro, da installare all'interno di Firefox o di Chrome e compatibile con tutti i principali sistemi operativi.
  • +
    + Aggiungendo questo link al tuo lettore di feed, rimarrai aggiornata.`, + ical_description: `I computer e gli smartphone sono comunemente attrezzati con un'applicazione per gestire un calendario. A questi programmi solitamente è possibile far importare un calendario remoto.`, + list_description: `Se hai un sito web e vuoi mostrare una lista di eventi, puoi usare il seguente codice` + }, + + register: { + description: `I movimenti hanno bisogno di organizzarsi e autofinanziarsi.
    Questo è un dono per voi, usatelo solo per eventi non commerciali e ovviamente antifascisti, antisessisti, antirazzisti. +
    Prima di poter pubblicare dobbiamo approvare l'account, considera che dietro questo sito ci sono delle persone di + carne e sangue, scrivici quindi due righe per farci capire che eventi vorresti pubblicare.`, + error: 'Errore: ', + admin_complete: 'Sei il primo utente e quindi sei amministratore!', + complete: 'Confermeremo la registrazione quanto prima.', + request: 'Richiesta di registrazione', + registration_email: `Ciao, + ci è arrivata una richiesta di registrazione su gancio, la confermeremo quanto prima.` + }, + + event: { + anon: 'Anonimo', + anon_description: `Puoi inserire un evento senza registrarti o fare il login, + ma in questo caso dovrai aspettare che qualcuno lo legga confermando che si + tratta di un evento adatto a questo spazio, delegando questa scelta. Inoltre non sarà possibile modificarlo.

    + Puoi invece fare il login o registrarti, + altrimenti vai avanti e riceverai una risposta il prima possibile. `, + multidate_description: 'tanti giorni', + date_description: `Quand'è il gancio?`, + dates_description: 'Che giorni?', + same_day: 'stesso giorno', + what_description: 'Nome evento', + description_description: 'Descrizione, dajene di copia/incolla', + tag_description: 'Tag...', + media_description: 'Puoi aggiungere un volantino', + time_start_description: 'Comincia alle', + time_end_description: 'Se vuoi puoi specificare un orario di fine.', + added: 'Evento aggiunto', + added_anon: 'Evento aggiunto, verrà confermato quanto prima.', + where_description: `Dov'è il gancio? Se il posto non è presente, scrivilo e premi invio. `, + confirmed: 'Evento confermato' + }, + + admin: { + mastodon_instance: 'Istanza', + mastodon_description: 'Puoi associare un account mastodon a questa istanza di gancio, ogni evento verrà pubblicato lì.', + place_description: `Nel caso in cui un luogo sia errato o cambi indirizzo, puoi modificarlo.
    Considera che tutti gli eventi associati a questo luogo cambieranno indirizzo (anche quelli passati!)`, + event_confirm_description: 'Puoi confermare qui gli eventi inseriti da utenti anonimi' + }, + + auth: { + not_confirmed: 'Non abbiamo ancora confermato questa mail...', + fail: 'Autenticazione fallita. Sicura la password è giusta? E la mail?' + }, + + settings: { + change_password: 'Cambia password' + }, + + err: { + register_error: 'Errore nella registrazione' + }, + + firstrun: { + basic: `Inserisci titolo e descrizione della tua istanza di gancio.`, + database: `Gancio ha bisogno di un database postgresql!`, + smtp: `Inserisci un account SMTP relativo a questa istanza di gancio.` + + } +} + +export default it diff --git a/middleware/README.md b/middleware/README.md new file mode 100644 index 00000000..01595ded --- /dev/null +++ b/middleware/README.md @@ -0,0 +1,8 @@ +# MIDDLEWARE + +**This directory is not required, you can delete it if you don't want to use it.** + +This directory contains your application middleware. +Middleware let you define custom functions that can be run before rendering either a page or a group of pages. + +More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware). diff --git a/nuxt.config.js b/nuxt.config.js new file mode 100644 index 00000000..42ae54ef --- /dev/null +++ b/nuxt.config.js @@ -0,0 +1,91 @@ +const argv = require('yargs').argv +const path = require('path') +const config_path = path.resolve(argv.config || './config.js') + +const config = require(config_path).SHARED_CONF + +module.exports = { + mode: 'universal', + /* + ** Headers of the page + */ + head: { + title: config.title, + meta: [ + { charset: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { hid: 'description', name: 'description', content: config.description } + ], + link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }] + }, + dev: (process.env.NODE_ENV !== 'production'), + serverMiddleware: [ + { path: '/api', handler: '@/server/api/index.js' } + ], + + /* + ** Customize the progress-bar color + */ + // loading: { color: '#fff' }, + + /* + ** Global CSS + */ + css: [ + 'bootstrap/dist/css/bootstrap.css', + 'element-ui/lib/theme-chalk/index.css' + ], + env: { + config + }, + /* + ** Plugins to load before mounting the App + */ + plugins: [ + '@/plugins/element-ui', // UI library -> https://element.eleme.io/#/en-US/ + '@/plugins/filters', // text filters, datetime, etc. + '@/plugins/i18n', // localization plugin + '@/plugins/vue-awesome', // icon + { src: '@/plugins/v-calendar', ssr: false } // calendar, TO-REDO + ], + + /* + ** Nuxt.js modules + */ + modules: [ + // Doc: https://axios.nuxtjs.org/usage + '@nuxtjs/axios', + '@nuxtjs/auth' + ], + /* + ** Axios module configuration + */ + axios: { + baseURL: config.baseurl + '/api', + browserBaseURL: config.baseurl + '/api', + prefix: '/api' + // credentials: true + // See https://github.com/nuxt-community/axios-module#options + }, + auth: { + strategies: { + local: { + endpoints: { + login: { url: '/auth/login', method: 'post', propertyName: 'token' }, + logout: false, + user: { url: '/auth/user', method: 'get', propertyName: false } + } + } + } + }, + + /* + ** Build configuration + */ + build: { + transpile: [/^element-ui/, /^vue-awesome/], + splitChunks: { + layouts: true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..42c985ff --- /dev/null +++ b/package.json @@ -0,0 +1,76 @@ +{ + "name": "gancio", + "version": "0.9.3", + "description": "My well-made Nuxt.js project", + "author": "lesion", + "scripts": { + "dev": "cross-env NODE_ENV=development nodemon server/index.js --watch server", + "build": "nuxt build", + "start": "cross-env NODE_ENV=production node server/index.js", + "generate": "nuxt generate", + "lint": "eslint --ext .js,.vue --ignore-path .gitignore .", + "precommit": "npm run lint", + "migrate:dev": "sequelize db:migrate", + "migrate": "NODE_ENV=production sequelize db:migrate" + }, + "bin": { + "gancio": "server/index.js" + }, + "files": [ + "server/*", + "nuxt.config.js", + "dist/*" + ], + "dependencies": { + "@nuxtjs/auth": "^4.6.5", + "@nuxtjs/axios": "^5.5.3", + "axios": "^0.19.0", + "bcrypt": "^3.0.5", + "body-parser": "^1.18.3", + "bootstrap": "4.3.1", + "cookie-parser": "^1.4.4", + "cors": "^2.8.5", + "cross-env": "^5.2.0", + "dayjs": "^1.8.14", + "element-ui": "^2.9.1", + "email-templates": "^5.1.0", + "express": "^4.17.1", + "express-jwt": "^5.3.1", + "ics": "^2.13.2", + "jsonwebtoken": "^8.5.1", + "less": "^3.9.0", + "mastodon-api": "^1.3.0", + "morgan": "^1.9.1", + "multer": "^1.4.1", + "nuxt": "^2.8.1", + "pg": "^7.11.0", + "sequelize": "^5.8.7", + "sequelize-cli": "^5.4.0", + "sharp": "^0.22.0", + "sqlite3": "^4.0.8", + "v-calendar": "^1.0.0-beta.14", + "vue-awesome": "^3.5.3", + "vue-i18n": "^8.10.0" + }, + "devDependencies": { + "@nuxtjs/eslint-config": "^0.0.1", + "babel-eslint": "^10.0.1", + "eslint": "^5.15.1", + "eslint-config-prettier": "^4.3.0", + "eslint-config-standard": ">=12.0.0", + "eslint-loader": "^2.1.2", + "eslint-plugin-import": ">=2.17.3", + "eslint-plugin-jest": ">=22.6.4", + "eslint-plugin-node": ">=9.1.0", + "eslint-plugin-nuxt": ">=0.4.2", + "eslint-plugin-prettier": "^3.1.0", + "eslint-plugin-promise": ">=4.0.1", + "eslint-plugin-standard": ">=4.0.0", + "eslint-plugin-vue": "^5.2.2", + "less-loader": "^5.0.0", + "nodemon": "^1.19.1", + "prettier": "^1.17.1", + "pug-plain-loader": "^1.0.0", + "webpack-cli": "^3.3.2" + } +} diff --git a/pages/README.md b/pages/README.md new file mode 100644 index 00000000..1d5d48b2 --- /dev/null +++ b/pages/README.md @@ -0,0 +1,6 @@ +# PAGES + +This directory contains your Application Views and Routes. +The framework reads all the `*.vue` files inside this directory and creates the router of your application. + +More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing). diff --git a/pages/about.vue b/pages/about.vue new file mode 100644 index 00000000..f0fd2b6d --- /dev/null +++ b/pages/about.vue @@ -0,0 +1,50 @@ + + diff --git a/pages/add/_edit.vue b/pages/add/_edit.vue new file mode 100644 index 00000000..11642eeb --- /dev/null +++ b/pages/add/_edit.vue @@ -0,0 +1,283 @@ + + \ No newline at end of file diff --git a/pages/admin.vue b/pages/admin.vue new file mode 100644 index 00000000..23e0c75a --- /dev/null +++ b/pages/admin.vue @@ -0,0 +1,234 @@ + + \ No newline at end of file diff --git a/pages/embed/list.vue b/pages/embed/list.vue new file mode 100644 index 00000000..1e711364 --- /dev/null +++ b/pages/embed/list.vue @@ -0,0 +1,29 @@ + + diff --git a/pages/event/_id.vue b/pages/event/_id.vue new file mode 100644 index 00000000..5e168453 --- /dev/null +++ b/pages/event/_id.vue @@ -0,0 +1,201 @@ + + + + diff --git a/pages/export.vue b/pages/export.vue new file mode 100644 index 00000000..bacef4b8 --- /dev/null +++ b/pages/export.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/pages/index.vue b/pages/index.vue new file mode 100644 index 00000000..a74231c1 --- /dev/null +++ b/pages/index.vue @@ -0,0 +1,29 @@ + + + diff --git a/pages/login.vue b/pages/login.vue new file mode 100644 index 00000000..82742265 --- /dev/null +++ b/pages/login.vue @@ -0,0 +1,83 @@ + + + diff --git a/pages/recover/_code.vue b/pages/recover/_code.vue new file mode 100644 index 00000000..01c337c9 --- /dev/null +++ b/pages/recover/_code.vue @@ -0,0 +1,54 @@ + + + + diff --git a/pages/register.vue b/pages/register.vue new file mode 100644 index 00000000..691355b2 --- /dev/null +++ b/pages/register.vue @@ -0,0 +1,63 @@ + + + diff --git a/pages/settings.vue b/pages/settings.vue new file mode 100644 index 00000000..80269052 --- /dev/null +++ b/pages/settings.vue @@ -0,0 +1,46 @@ + + + diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 00000000..ca1f9d8a --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,7 @@ +# PLUGINS + +**This directory is not required, you can delete it if you don't want to use it.** + +This directory contains Javascript plugins that you want to run before mounting the root Vue.js application. + +More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins). diff --git a/plugins/element-ui.js b/plugins/element-ui.js new file mode 100644 index 00000000..7293e6fe --- /dev/null +++ b/plugins/element-ui.js @@ -0,0 +1,50 @@ +import Vue from 'vue' +import { Button, Select, Tag, Option, Table, FormItem, Card, Row, Col, Upload, Checkbox, + Form, Tabs, TabPane, Switch, Input, Loading, TimeSelect, Badge, ButtonGroup, Divider, Step, Steps, + TableColumn, ColorPicker, Pagination, Popover, Tooltip, Dialog, Calendar, Carousel, CarouselItem, + Container, Footer , Timeline, TimelineItem, Menu, MenuItem } from 'element-ui' +import localeEn from 'element-ui/lib/locale/lang/en' +import localeIt from 'element-ui/lib/locale/lang/it' +import locale from 'element-ui/lib/locale' +locale.use(localeIt) + +export default () => { + Vue.use(Button) + Vue.use(Divider) + Vue.use(Step) + Vue.use(Steps) + Vue.use(Checkbox) + Vue.use(Upload) + Vue.use(ButtonGroup) + Vue.use(Calendar) + Vue.use(Row) + Vue.use(Col) + Vue.use(Carousel) + Vue.use(CarouselItem) + Vue.use(Badge) + Vue.use(Dialog) + Vue.use(Menu) + Vue.use(MenuItem) + Vue.use(Container) + Vue.use(Timeline) + Vue.use(TimelineItem) + Vue.use(Footer) + Vue.use(Tooltip) + Vue.use(Popover) + Vue.use(Card) + Vue.use(Select) + Vue.use(Tag) + Vue.use(Input) + Vue.use(Tabs) + Vue.use(TabPane) + Vue.use(Option) + Vue.use(Switch) + Vue.use(ColorPicker) + Vue.use(Table) + Vue.use(TableColumn) + Vue.use(Pagination) + Vue.use(FormItem) + Vue.use(Form) + Vue.use(TimeSelect) + Vue.use(Loading.directive) +} diff --git a/plugins/filters.js b/plugins/filters.js new file mode 100644 index 00000000..5dc4d672 --- /dev/null +++ b/plugins/filters.js @@ -0,0 +1,31 @@ +import Vue from 'vue' +import moment from 'dayjs' +import 'dayjs/locale/it' +moment.locale('it') + +function short_hour(datetime) { + if (datetime.minute() === 0) { + return 'h' + datetime.format('HH') + } else { + return 'h' + datetime.format('HH:mm') + } +} + +export default (a) => { + Vue.filter('linkify', value => value.replace(/(https?:\/\/[^\s]+)/g, '$1')) + Vue.filter('datetime', value => moment(value).format('ddd, D MMMM HH:mm')) + Vue.filter('short_datetime', value => moment(value).format('D/MM HH:mm')) + Vue.filter('hour', value => moment(value).format('HH:mm')) + Vue.filter('day', value => moment(value).format('dddd, D MMMM')) + Vue.filter('month', value => moment(value).format('MMM')) + Vue.filter('event_when', event => { + const start = moment(event.start_datetime) + const end = moment(event.end_datetime) + if (event.multidate) { + return `${start.format('ddd, D MMMM')} (${short_hour(start)}) - ${end.format('ddd, D MMMM')} (${short_hour(end)})` + } else if (event.end_datetime && event.end_datetime !== event.start_datetime) + return `${start.format('ddd, D MMMM')} (${short_hour(start)}-${short_hour(end)}` + else + return `${start.format('dddd, D MMMM')} (${short_hour(start)})` + }) +} diff --git a/plugins/i18n.js b/plugins/i18n.js new file mode 100644 index 00000000..03dc32b4 --- /dev/null +++ b/plugins/i18n.js @@ -0,0 +1,25 @@ +import Vue from 'vue' +import VueI18n from 'vue-i18n' +import it from '@/locales/it.js' + +Vue.use(VueI18n) + +export default ({ app, store }) => { + // Set i18n instance on app + // This way we can use it in middleware and pages asyncData/fetch + app.i18n = new VueI18n({ + locale: store.state.locale, + fallbackLocale: 'it', + messages: { + it + } + }) + + // app.i18n.path = (link) => { + // if (app.i18n.locale === app.i18n.fallbackLocale) { + // return `/${link}` + // } + + // return `/${app.i18n.locale}/${link}` + // } +} diff --git a/plugins/initialize.js b/plugins/initialize.js new file mode 100644 index 00000000..821bfe83 --- /dev/null +++ b/plugins/initialize.js @@ -0,0 +1,9 @@ + +// TOFIX: not needed in any case (eg. embed) +export default async ({ store, $axios }) => { + // const now = new Date() + // const events = await $axios.$get(`/event/${now.getMonth()}/${now.getFullYear()}`) + // store.commit('setEvents', events) + // const { tags, places } = await $axios.$get('/event/meta') + // store.commit('update', { tags, places }) +} diff --git a/plugins/v-calendar.js b/plugins/v-calendar.js new file mode 100644 index 00000000..14ecafaf --- /dev/null +++ b/plugins/v-calendar.js @@ -0,0 +1,8 @@ +import Vue from 'vue' +import VCalendar from 'v-calendar' + +export default () => { + Vue.use(VCalendar, { + firstDayOfWeek: 2 + }) +} diff --git a/plugins/vue-awesome.js b/plugins/vue-awesome.js new file mode 100644 index 00000000..2dac2a63 --- /dev/null +++ b/plugins/vue-awesome.js @@ -0,0 +1,33 @@ +import Vue from 'vue' +import 'vue-awesome/icons/lock' +import 'vue-awesome/icons/user' +import 'vue-awesome/icons/plus' +import 'vue-awesome/icons/cog' +import 'vue-awesome/icons/tools' +import 'vue-awesome/icons/file-export' +import 'vue-awesome/icons/sign-out-alt' +import 'vue-awesome/icons/clock' +import 'vue-awesome/icons/map-marker-alt' +import 'vue-awesome/icons/file-alt' +import 'vue-awesome/icons/image' +import 'vue-awesome/icons/tag' +import 'vue-awesome/icons/users' +import 'vue-awesome/icons/calendar' +import 'vue-awesome/icons/edit' +import 'vue-awesome/icons/envelope-open-text' +import 'vue-awesome/icons/user-secret' +import 'vue-awesome/icons/question-circle' +import 'vue-awesome/icons/share' +import 'vue-awesome/icons/comment' +import 'vue-awesome/icons/comments' +import 'vue-awesome/icons/tags' +import 'vue-awesome/icons/chevron-right' +import 'vue-awesome/icons/chevron-left' +import 'vue-awesome/icons/search' +import 'vue-awesome/icons/times' + +import Icon from 'vue-awesome/components/Icon' + +export default () => { + Vue.component('v-icon', Icon) +} diff --git a/plugins/vuex-persist.js b/plugins/vuex-persist.js new file mode 100644 index 00000000..a0cf9450 --- /dev/null +++ b/plugins/vuex-persist.js @@ -0,0 +1,9 @@ +// // ~/plugins/vuex-persist.js +// import VuexPersistence from 'vuex-persist' + +// export default ({ store }) => { +// const per = new VuexPersistence({ +// reducer: state => ({ logged: state.logged, user: state.user, token: state.token }) +// }).plugin(store) +// store.dispatch('login') +// } diff --git a/server/api/auth.js b/server/api/auth.js new file mode 100644 index 00000000..7e7a8cdc --- /dev/null +++ b/server/api/auth.js @@ -0,0 +1,52 @@ +const { Op } = require('sequelize') +const { user: User } = require('./models') +const Settings = require('./controller/settings') + +const Auth = { + async fillUser(req, res, next) { + if (!req.user) return next() + req.user = await User.findOne({ + where: { id: { [Op.eq]: req.user.id }, is_active: true } + }).catch(e => { + res.sendStatus(404) + return next(false) + }) + next() + }, + async isAuth(req, res, next) { + if (!req.user) { + return res + .status(403) + .send({ message: 'Failed to authenticate token ' }) + } + + req.user = await User.findOne({ + where: { id: { [Op.eq]: req.user.id }, is_active: true } + }) + if (!req.user) { + return res + .status(403) + .send({ message: 'Failed to authenticate token ' + err }) + } + next() + }, + isAdmin(req, res, next) { + if (!req.user) { + return res + .status(403) + .send({ message: 'Failed to authenticate token ' }) + } + if (req.user.is_admin && req.user.is_active) return next() + return res.status(403).send({ message: 'Admin needed' }) + }, + async adminOrFirstRun(req, res, next) { + if (req.user && req.user.is_admin && req.user.is_active) return next() + const settings = await Settings.settings() + if (!settings.firstRun) { + return next() + } + } + +} + +module.exports = Auth diff --git a/server/api/controller/bot.js b/server/api/controller/bot.js new file mode 100644 index 00000000..a1b8ba81 --- /dev/null +++ b/server/api/controller/bot.js @@ -0,0 +1,95 @@ +const fs = require('fs') +const path = require('path') +const moment = require('moment') +const { event: Event, comment: Comment, tag: Tag } = require('../model') +const config = require('../../config').SHARED_CONF +const Mastodon = require('mastodon-api') +const settingsController = require('./settings') +moment.locale('it') + +const botController = { + bot: null, + async initialize() { + console.error('dentro bot inizialiteds') + const settings = await settingsController.settings() + if (!settings.mastodon_auth || !settings.mastodon_auth.access_token) return + const mastodon_auth = settings.mastodon_auth + botController.bot = new Mastodon({ + access_token: mastodon_auth.access_token, + api_url: `https://${mastodon_auth.instance}/api/v1` + }) + const listener = botController.bot.stream('/streaming/direct') + listener.on('message', botController.message) + listener.on('error', botController.error) + // const botUsers = await User.findAll({ where: { mastodon_auth: { [Op.ne]: null } } }) + // console.log(botUsers) + // botController.bots = botUsers.map(user => { + // console.log('initialize bot ', user.name) + // console.log('.. ', user.mastodon_auth) + // const { client_id, client_secret, access_token } = user.mastodon_auth + // const bot = new Mastodon({ access_token, api_url: `https://${user.mastodon_instance}/api/v1/` }) + // const listener = bot.stream('streaming/direct') + // listener.on('message', botController.message) + // return { email: user.email, bot } + // }) + // console.log(botController.bots) + // }, + // add (user, token) { + // const bot = new Mastodon({ access_token: user.mastodon_auth.access_token, api_url: `https://${user.mastodon_instance}/api/v1/` }) + // const listener = bot.stream('streaming/direct') + // listener.on('message', botController.message) + // listener.on('error', botController.error) + // botController.bots.push({ email: user.email, bot }) + }, + async post(mastodon_auth, event) { + const { access_token, instance } = mastodon_auth + const bot = new Mastodon({ access_token, api_url: `https://${instance}/api/v1/` }) + const status = `${event.title} @ ${event.place.name} ${moment(event.start_datetime).format('ddd, D MMMM HH:mm')} - +${event.description.length > 200 ? event.description.substr(0, 200) + '...' : event.description} - ${event.tags.map(t => '#' + t.tag).join(' ')} ${config.baseurl}/event/${event.id}` + + let media + if (event.image_path) { + const file = path.join(__dirname, '..', '..', '..', 'uploads', event.image_path) + if (fs.statSync(file)) { + media = await bot.post('media', { file: fs.createReadStream(file) }) + } + } + return bot.post('statuses', { status, visibility: 'direct', media_ids: media ? [media.data.id] : [] }) + }, + + // TOFIX: enable message deletion + async message(msg) { + const replyid = msg.data.in_reply_to_id || msg.data.last_status.in_reply_to_id + if (!replyid) return + const event = await Event.findOne({ where: { activitypub_id: replyid } }) + if (!event) { + // check for comment.. + // const comment = await Comment.findOne( {where: { }}) + return + } + const comment = await Comment.create({ + activitypub_id: msg.data.last_status.id, + // text: msg.data.last_status.content, + data: msg.data + // author: msg.data.accounts[0].username + }) + event.addComment(comment) + // const comment = await Comment.findOne( { where: {activitypub_id: msg.data.in_reply_to}} ) + // console.log('dentro message ', data) + + // add comment to specified event + // let comment + // if (!event) { + // const comment = await Comment.findOne({where: {activitypub_id: req.body.id}, include: Event}) + // event = comment.event + // } + // const comment = new Comment(req.body) + // event.addComment(comment) + }, + error(err) { + console.log('error ', err) + } +} + +// setTimeout(botController.initialize, 2000) +module.exports = botController diff --git a/server/api/controller/event.js b/server/api/controller/event.js new file mode 100644 index 00000000..fd83e4e2 --- /dev/null +++ b/server/api/controller/event.js @@ -0,0 +1,194 @@ +const crypto = require('crypto') +const moment = require('moment') +const { Op } = require('sequelize') +const lodash = require('lodash') +const { event: Event, comment: Comment, tag: Tag, place: Place, notification: Notification } = require('../models') +const Sequelize = require('sequelize') + +const eventController = { + + async addComment(req, res) { + // comment could be added to an event or to another comment + let event = await Event.findOne({ where: { activitypub_id: { [Op.eq]: req.body.id } } }) + if (!event) { + const comment = await Comment.findOne({ where: { activitypub_id: { [Op.eq]: req.body.id } }, include: Event }) + event = comment.event + } + const comment = new Comment(req.body) + event.addComment(comment) + res.json(comment) + }, + + async getMeta(req, res) { + const places = await Place.findAll({ + order: [[Sequelize.literal('weigth'), 'DESC']], + attributes: { + include: [[Sequelize.fn('count', Sequelize.col('events.placeId')) , 'weigth']], // <---- Here you will get the total count of user + exclude: ['weigth', 'createdAt', 'updatedAt'] + }, + include: [{ model: Event, attributes: [] }], + group: ['place.id'] + }) + + const tags = await Tag.findAll({ + order: [['weigth', 'DESC']], + includeIgnoreAttributes: false, + attributes: { + exclude: ['createdAt', 'updatedAt'] + } + }) + + res.json({ tags, places }) + }, + + async getNotifications(event) { + function match(event, filters) { + // matches if no filter specified + if (!filters) return true + + // check for visibility + if (typeof filters.is_visible !== 'undefined' && filters.is_visible !== event.is_visible) return false + + if (!filters.tags && !filters.places) return true + if (!filters.tags.length && !filters.places.length) return true + if (filters.tags.length) { + const m = lodash.intersection(event.tags.map(t => t.tag), filters.tags) + if (m.length > 0) return true + } + if (filters.places.length) { + if (filters.places.find(p => p === event.place.name)) { + return true + } + } + } + const notifications = await Notification.findAll() + + // get notification that matches with selected event + return notifications.filter(notification => match(event, notification.filters)) + }, + + async updateTag(req, res) { + const tag = await Tag.findByPk(req.body.tag) + if (tag) { + res.json(await tag.update(req.body)) + } else { + res.sendStatus(404) + } + }, + + async updatePlace(req, res) { + const place = await Place.findByPk(req.body.id) + await place.update(req.body) + res.json(place) + }, + + async get(req, res) { + const id = req.params.event_id + const event = await Event.findByPk(id, { include: + [ + Tag, + Comment, + { model: Place, attributes: ['name', 'address'] } + ], + order: [ [Comment, 'id', 'DESC'], [Tag, 'weigth', 'DESC'] ] + }) + res.json(event) + }, + + async confirm(req, res) { + const id = req.params.event_id + const event = await Event.findByPk(id) + + try { + await event.update({ is_visible: true }) + // insert notification + const notifications = await eventController.getNotifications(event) + await event.setNotifications(notifications) + res.sendStatus(200) + } catch (e) { + res.sendStatus(404) + } + }, + + async unconfirm(req, res) { + const id = req.params.event_id + const event = await Event.findByPk(id) + + try { + await event.update({ is_visible: false }) + res.sendStatus(200) + } catch (e) { + res.sendStatus(404) + } + }, + + async getUnconfirmed(req, res) { + const events = await Event.findAll({ + where: { + is_visible: false + }, + order: [['start_datetime', 'ASC']], + include: [Tag, Place] + }) + res.json(events) + }, + + async addNotification(req, res) { + try { + const notification = { + filters: { is_visible: true }, + email: req.body.email, + type: 'mail', + remove_code: crypto.randomBytes(16).toString('hex') + } + await Notification.create(notification) + res.sendStatus(200) + } catch (e) { + res.sendStatus(404) + } + }, + + async delNotification(req, res) { + const remove_code = req.params.code + try { + const notification = await Notification.findOne({ where: { remove_code: { [Op.eq]: remove_code } } }) + await notification.destroy() + } catch (e) { + return res.sendStatus(404) + } + res.sendStatus(200) + }, + + async getAll(req, res) { + // this is due how v-calendar shows dates + const start = moment().year(req.params.year).month(req.params.month) + .startOf('month').startOf('isoWeek') + let end = moment().year(req.params.year).month(req.params.month).endOf('month') + const shownDays = end.diff(start, 'days') + if (shownDays <= 34) end = end.add(1, 'week') + end = end.endOf('isoWeek') + const events = await Event.findAll({ + where: { + is_visible: true, + [Op.and]: [ + { start_datetime: { [Op.gte]: start } }, + { start_datetime: { [Op.lte]: end } } + ] + }, + order: [ + ['start_datetime', 'ASC'], + [Tag, 'weigth', 'DESC'] + ], + include: [ + // { model: User, required: false }, + // { type: Comment, required: false, attributes: [''] + { model: Tag, required: false, attributes: ['tag', 'weigth', 'color'] }, + { model: Place, required: false, attributes: ['id', 'name', 'address'] } + ] + }) + res.json(events) + } + +} + +module.exports = eventController diff --git a/server/api/controller/export.js b/server/api/controller/export.js new file mode 100644 index 00000000..f0b5ba26 --- /dev/null +++ b/server/api/controller/export.js @@ -0,0 +1,70 @@ +const { event: Event, place: Place } = require('../models') +const { Op } = require('sequelize') +const moment = require('moment') +const ics = require('ics') + +const exportController = { + + async export(req, res) { + console.log('type ', req.params.type) + console.error(req) + const type = req.params.type + const tags = req.query.tags + const places = req.query.places + const whereTag = {} + const wherePlace = {} + const yesterday = moment().subtract('1', 'day') + if (tags) { + whereTag.tag = tags.split(',') + } + if (places) { + wherePlace.id = places.split(',') + } + const events = await Event.findAll({ + order: ['start_datetime'], + where: { + is_visible: true, + start_datetime: { [Op.gte]: yesterday }, + placeId: places.split(',') + }, + attributes: { + exclude: ['createdAt', 'updatedAt'] + }, + include: [{ model: Place, attributes: ['name', 'id', 'address', 'weigth'] }] + }) + switch (type) { + case 'feed': + return exportController.feed(res, events.slice(0, 20)) + case 'ics': + return exportController.ics(res, events) + case 'json': + return res.json(events) + } + }, + + feed(res, events) { + res.type('application/rss+xml; charset=UTF-8') + res.render('feed/rss.pug', { events, config: process.env.config, moment }) + }, + + ics(res, events) { + const eventsMap = events.map(e => { + const tmpStart = moment(e.start_datetime) + const tmpEnd = moment(e.end_datetime) + const start = [tmpStart.year(), tmpStart.month() + 1, tmpStart.date(), tmpStart.hour(), tmpStart.minute()] + const end = [tmpEnd.year(), tmpEnd.month() + 1, tmpEnd.date(), tmpEnd.hour(), tmpEnd.minute()] + return { + start, + end, + title: e.title, + description: e.description, + location: e.place.name + ' ' + e.place.address + } + }) + res.type('text/calendar; charset=UTF-8') + const { error, value } = ics.createEvents(eventsMap) + res.send(value) + } +} + +module.exports = exportController diff --git a/server/api/controller/settings.js b/server/api/controller/settings.js new file mode 100644 index 00000000..4830c85f --- /dev/null +++ b/server/api/controller/settings.js @@ -0,0 +1,61 @@ +const Mastodon = require('mastodon-api') +const { setting: Setting } = require('../models') + +const baseurl = process.env.baseurl + +const settingsController = { + + async setAdminSetting(key, value) { + await Setting.findOrCreate({ where: { key }, + defaults: { value } }) + .spread((settings, created) => { + if (!created) return settings.update({ value }) + }) + }, + + async getAdminSettings(req, res) { + const settings = await settingsController.settings() + res.json(settings) + }, + + async getAuthURL(req, res) { + const instance = req.body.instance + const callback = `${baseurl}/api/settings/oauth` + const { client_id, client_secret } = await Mastodon.createOAuthApp(`https://${instance}/api/v1/apps`, + 'gancio', 'read write', callback) + const url = await Mastodon.getAuthorizationUrl(client_id, client_secret, + `https://${instance}`, 'read write', callback) + + await settingsController.setAdminSetting('mastodon_auth', { client_id, client_secret, instance }) + res.json(url) + }, + + async code(req, res) { + const code = req.query.code + let client_id, client_secret, instance + const callback = `${baseurl}/api/settings/oauth` + + const settings = await settingsController.settings() + + ({ client_id, client_secret, instance } = settings.mastodon_auth) + + try { + const token = await Mastodon.getAccessToken(client_id, client_secret, code, + `https://${instance}`, callback) + const mastodon_auth = { client_id, client_secret, access_token: token, instance } + await settingsController.setAdminSetting('mastodon_auth', mastodon_auth) + + res.redirect('/admin') + } catch (e) { + res.json(e) + } + }, + + async settings() { + const settings = await Setting.findAll() + return settings + } + +} + +module.exports = settingsController diff --git a/server/api/controller/user.js b/server/api/controller/user.js new file mode 100644 index 00000000..f8668450 --- /dev/null +++ b/server/api/controller/user.js @@ -0,0 +1,250 @@ +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const jwt = require('jsonwebtoken') +const { Op } = require('sequelize') +const jsonwebtoken = require('jsonwebtoken') +const mail = require('../mail') +const { user: User, event: Event, tag: Tag, place: Place } = require('../models') +const eventController = require('./event') +const config = require('../../config') + +const userController = { + async login(req, res) { + // find the user + const user = await User.findOne({ where: { email: { [Op.eq]: req.body && req.body.email } } }) + if (!user) { + res.status(403).json({ success: false, message: 'auth.fail' }) + } else if (user) { + if (!user.is_active) { + res.status(403).json({ success: false, message: 'auth.not_confirmed' }) + // check if password matches + } else if (!await user.comparePassword(req.body.password)) { + res.status(403).json({ success: false, message: 'auth.fail' }) + } else { + // if user is found and password is right + // create a token + const accessToken = jsonwebtoken.sign( + { + id: user.id, + email: user.email, + scope: [user.is_admin ? 'admin' : 'user'] + }, + config.SECRET_CONF.secret + ) + + res.json({ token: accessToken }) + } + } + }, + + async logout(req, res) { + + }, + + async setToken(req, res) { + req.user.mastodon_auth = req.body + await req.user.save() + res.json(req.user) + }, + + async delEvent(req, res) { + const event = await Event.findByPk(req.params.id) + // check if event is mine (or user is admin) + if (event && (req.user.is_admin || req.user.id === event.userId)) { + if (event.image_path) { + const old_path = path.resolve(__dirname, '..', '..', 'uploads', event.image_path) + const old_thumb_path = path.resolve(__dirname, '..', '..', 'uploads', 'thumb', event.image_path) + try { + await fs.unlink(old_path) + await fs.unlink(old_thumb_path) + } catch (e) { + console.error(e) + } + } + await event.destroy() + res.sendStatus(200) + } else { + res.sendStatus(403) + } + }, + + // ADD EVENT + async addEvent(req, res) { + const body = req.body + + const eventDetails = { + title: body.title, + description: body.description.replace(/(<([^>]+)>)/ig, ''), + multidate: body.multidate, + start_datetime: body.start_datetime, + end_datetime: body.end_datetime, + + // publish this event if authenticated + is_visible: !!req.user + } + + if (req.file) { + eventDetails.image_path = req.file.filename + } + + let event = await Event.create(eventDetails) + + // create place if needs to + let place + try { + place = await Place.findOrCreate({ where: { name: body.place_name }, + defaults: { address: body.place_address } }) + .spread((place, created) => place) + await event.setPlace(place) + } catch (e) { + console.error(e) + } + + // create/assign tags + if (body.tags) { + await Tag.bulkCreate(body.tags.map(t => ({ tag: t })), { ignoreDuplicates: true }) + const tags = await Tag.findAll({ where: { tag: { [Op.in]: body.tags } } }) + await event.addTags(tags) + } + if (req.user) await req.user.addEvent(event) + event = await Event.findByPk(event.id, { include: [User, Tag, Place] }) + + // insert notifications + const notifications = await eventController.getNotifications(event) + await event.setNotifications(notifications) + + return res.json(event) + }, + + async updateEvent(req, res) { + const body = req.body + const event = await Event.findByPk(body.id) + if (!req.user.is_admin && event.userId !== req.user.id) { + return res.sendStatus(403) + } + + if (req.file) { + if (event.image_path) { + const old_path = path.resolve(__dirname, '..', '..', 'uploads', event.image_path) + const old_thumb_path = path.resolve(__dirname, '..', '..', 'uploads', 'thumb', event.image_path) + await fs.unlink(old_path, e => console.error(e)) + await fs.unlink(old_thumb_path, e => console.error(e)) + } + body.image_path = req.file.filename + } + + body.description = body.description + .replace(/(<([^>]+)>)/ig, '') // remove all tags from description + // .replace(/(https?:\/\/[^\s]+)/g, '$1') // add links + + await event.update(body) + let place + try { + place = await Place.findOrCreate({ where: { name: body.place_name }, + defaults: { address: body.place_address } }) + .spread((place, created) => place) + } catch (e) { + console.log('error', e) + } + await event.setPlace(place) + await event.setTags([]) + if (body.tags) { + await Tag.bulkCreate(body.tags.map(t => ({ tag: t })), { ignoreDuplicates: true }) + const tags = await Tag.findAll({ where: { tag: { [Op.in]: body.tags } } }) + await event.addTags(tags) + } + const newEvent = await Event.findByPk(event.id, { include: [User, Tag, Place] }) + return res.json(newEvent) + }, + + async forgotPassword(req, res) { + const email = req.body.email + const user = await User.findOne({ where: { email: { [Op.eq]: email } } }) + if (!user) return res.sendStatus(200) + + user.recover_code = crypto.randomBytes(16).toString('hex') + mail.send(user.email, 'recover', { user, config: config.SHARED_CONF }) + + await user.save() + res.sendStatus(200) + }, + + async checkRecoverCode(req, res) { + const recover_code = req.body.recover_code + if (!recover_code) return res.sendStatus(400) + const user = await User.findOne({ where: { recover_code: { [Op.eq]: recover_code } } }) + if (!user) return res.sendStatus(400) + res.json(user) + }, + + async updatePasswordWithRecoverCode(req, res) { + const recover_code = req.body.recover_code + const password = req.body.password + if (!recover_code || !password) return res.sendStatus(400) + const user = await User.findOne({ where: { recover_code: { [Op.eq]: recover_code } } }) + if (!user) return res.sendStatus(400) + user.password = password + try { + await user.save() + res.sendStatus(200) + } catch (e) { + res.sendStatus(400) + } + }, + + current(req, res) { + if (req.user) { res.json(req.user) } else { res.sendStatus(404) } + }, + + async getAll(req, res) { + const users = await User.findAll({ + order: [['createdAt', 'DESC']] + }) + res.json(users) + }, + + async update(req, res) { + const user = await User.findByPk(req.body.id) + console.error(req.body.id) + if (user) { + if (!user.is_active && req.body.is_active) { + await mail.send(user.email, 'confirm', { user, config: config.SHARED_CONF }) + } + await user.update(req.body) + res.json(user) + } else { + res.sendStatus(400) + } + }, + + async register(req, res) { + const n_users = await User.count() + try { + // the first registered user will be an active admin + if (n_users === 0) { + req.body.is_active = req.body.is_admin = true + } else { + req.body.is_active = false + } + + const user = await User.create(req.body) + try { + mail.send([user.email, config.SECRET_CONF.admin], 'register', { user, config: config.SHARED_CONF }) + } catch (e) { + return res.status(400).json(e) + } + const payload = { + id: user.id, + email: user.email, + scope: [user.is_admin ? 'admin' : 'user'] + } + const token = jwt.sign(payload, config.SECRET_CONF.secret) + res.json({ token, user }) + } catch (e) { + res.status(404).json(e) + } + } +} + +module.exports = userController diff --git a/server/api/index.js b/server/api/index.js new file mode 100644 index 00000000..29a74c13 --- /dev/null +++ b/server/api/index.js @@ -0,0 +1,107 @@ +const express = require('express') +const multer = require('multer') +const cookieParser = require('cookie-parser') +const bodyParser = require('body-parser') +const expressJwt = require('express-jwt') +const config = require('../config') + +const { fillUser, isAuth, isAdmin } = require('./auth') +const eventController = require('./controller/event') +const exportController = require('./controller/export') +const userController = require('./controller/user') +const settingsController = require('./controller/settings') + +const storage = require('./storage')({ + destination: 'uploads/' +}) + +const upload = multer({ storage }) +const api = express.Router() +api.use(cookieParser()) +api.use(bodyParser.urlencoded({ extended: false })) +api.use(bodyParser.json()) + +const jwt = expressJwt({ + secret: config.SECRET_CONF.secret, + credentialsRequired: false +}) + +function errorHandler(fn) { + return async (req, res) => { + try { + await fn(req, res) + } catch (e) { + console.error(String(e)) + return res.status(500).json(e) + } + } +} + +// AUTH +api.post('/auth/login', userController.login) +api.post('/auth/logout', userController.logout) +api.get('/auth/user', jwt, fillUser, userController.current) + +api.post('/user/recover', userController.forgotPassword) +api.post('/user/check_recover_code', userController.checkRecoverCode) +api.post('/user/recover_password', userController.updatePasswordWithRecoverCode) + +api + .route('/user') + // register + .post(userController.register) + // get current user + // .get(isAuth, userController.current) + // update user (eg. confirm) + .put(jwt, isAuth, isAdmin, userController.update) + +// get all users +api.get('/users', jwt, isAuth, isAdmin, userController.getAll) + +// update a tag (modify color) +api.put('/tag', jwt, isAuth, isAdmin, eventController.updateTag) + +// update a place (modify address..) +api.put('/place', jwt, isAuth, isAdmin, eventController.updatePlace) + +api + .route('/user/event') + // add event + .post(jwt, fillUser, upload.single('image'), userController.addEvent) + // update event + .put(jwt, isAuth, upload.single('image'), userController.updateEvent) + +// remove event +api.delete('/user/event/:id', jwt, isAuth, userController.delEvent) + +// get tags/places +api.get('/event/meta', eventController.getMeta) + +// get unconfirmed events +api.get('/event/unconfirmed', jwt, isAuth, isAdmin, eventController.getUnconfirmed) + +// add event notification +api.post('/event/notification', eventController.addNotification) +api.delete('/event/notification/:code', eventController.delNotification) + +api.get('/settings', jwt, fillUser, isAdmin, settingsController.getAdminSettings) +api.post('/settings', jwt, fillUser, isAdmin, settingsController.setAdminSetting) + +// get event +api.get('/event/:event_id', eventController.get) + +// confirm event +api.get('/event/confirm/:event_id', jwt, isAuth, isAdmin, eventController.confirm) +api.get('/event/unconfirm/:event_id', jwt, isAuth, isAdmin, eventController.unconfirm) + +// export events (rss/ics) +api.get('/export/:type', exportController.export) + +// get events in this range +api.get('/event/:month/:year', errorHandler(eventController.getAll)) + +// mastodon oauth auth +api.post('/settings/getauthurl', jwt, isAuth, isAdmin, settingsController.getAuthURL) +api.get('/settings/oauth', jwt, isAuth, isAdmin, settingsController.code) + +module.exports = api diff --git a/server/api/mail.js b/server/api/mail.js new file mode 100644 index 00000000..bba20259 --- /dev/null +++ b/server/api/mail.js @@ -0,0 +1,49 @@ +const Email = require('email-templates') +const path = require('path') +const moment = require('moment') +const config = require('../config') + +moment.locale(config.SHARED_CONF.locale) +const mail = { + send(addresses, template, locals) { + const email = new Email({ + views: { root: path.join(__dirname, '..', 'emails') }, + htmlToText: false, + juice: true, + juiceResources: { + preserveImportant: true, + webResources: { + relativeTo: path.join(__dirname, '..', 'emails') + } + }, + message: { + from: `${config.SHARED_CONF.title} <${config.SECRET_CONF.smtp.auth.user}>` + }, + send: true, + i18n: { + directory: path.join(__dirname, '..', '..', 'locales', 'email'), + defaultLocale: config.SHARED_CONF.locale + }, + transport: config.SECRET_CONF.smtp + }) + const msg = { + template, + message: { + to: addresses, + bcc: config.SECRET_CONF.admin + }, + locals: { + ...locals, + locale: config.SHARED_CONF.locale, + config: config.SHARED_CONF, + datetime: datetime => moment(datetime).format('ddd, D MMMM HH:mm') + } + } + return email.send(msg) + .catch(e => { + console.error(e) + }) + } +} + +module.exports = mail diff --git a/server/api/models/comment.js b/server/api/models/comment.js new file mode 100644 index 00000000..acba9a7c --- /dev/null +++ b/server/api/models/comment.js @@ -0,0 +1,13 @@ +'use strict' +module.exports = (sequelize, DataTypes) => { + const comment = sequelize.define('comment', { + activitypub_id: DataTypes.BIGINT, + data: DataTypes.JSON + }, {}) + comment.associate = function (models) { + comment.belongsTo(models.event) + // Event.hasMany(Comment) + // associations can be defined here + } + return comment +}; diff --git a/server/api/models/event.js b/server/api/models/event.js new file mode 100644 index 00000000..8af98bdc --- /dev/null +++ b/server/api/models/event.js @@ -0,0 +1,31 @@ +'use strict' +module.exports = (sequelize, DataTypes) => { + const event = sequelize.define('event', { + title: DataTypes.STRING, + slug: DataTypes.STRING, + description: DataTypes.TEXT, + multidate: DataTypes.BOOLEAN, + start_datetime: { + type: DataTypes.DATE, + index: true + }, + end_datetime: DataTypes.DATE, + image_path: DataTypes.STRING, + is_visible: DataTypes.BOOLEAN, + activitypub_id: { + type: DataTypes.BIGINT, + index: true + } + }, {}) + event.associate = function (models) { + event.belongsTo(models.place) + event.belongsTo(models.user) + event.belongsToMany(models.tag, { through: 'event_tags' }) + event.belongsToMany(models.notification, { through: 'event_notification' }) + event.hasMany(models.comment) + // Tag.belongsToMany(Event, { through: 'tagEvent' }) + // Event.hasMany(models.Tag) + // associations can be defined here + } + return event +}; diff --git a/server/api/models/eventnotification.js b/server/api/models/eventnotification.js new file mode 100644 index 00000000..bc8f9ecb --- /dev/null +++ b/server/api/models/eventnotification.js @@ -0,0 +1,16 @@ +'use strict' +module.exports = (sequelize, DataTypes) => { + const eventNotification = sequelize.define('eventNotification', { + status: { + type: DataTypes.ENUM, + values: ['new', 'sent', 'error'], + defaultValue: 'new', + index: true + } + }, {}) + + eventNotification.associate = function (models) { + // associations can be defined here + } + return eventNotification +} diff --git a/server/api/models/index.js b/server/api/models/index.js new file mode 100644 index 00000000..d1a23202 --- /dev/null +++ b/server/api/models/index.js @@ -0,0 +1,31 @@ +const argv = require('yargs').argv +const fs = require('fs') +const path = require('path') +const Sequelize = require('sequelize') +const config_path = path.resolve(argv.config || './config.js') +const basename = path.basename(__filename) +const config = require(config_path).SECRET_CONF.db +const db = {} + +const sequelize = new Sequelize(config) + +fs + .readdirSync(__dirname) + .filter(file => { + return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js') + }) + .forEach(file => { + const model = sequelize.import(path.join(__dirname, file)) + db[model.name] = model + }) + +Object.keys(db).forEach(modelName => { + if (db[modelName].associate) { + db[modelName].associate(db) + } +}) + +db.sequelize = sequelize +db.Sequelize = Sequelize + +module.exports = db diff --git a/server/api/models/notification.js b/server/api/models/notification.js new file mode 100644 index 00000000..e49bc9f4 --- /dev/null +++ b/server/api/models/notification.js @@ -0,0 +1,17 @@ +'use strict' +module.exports = (sequelize, DataTypes) => { + const notification = sequelize.define('notification', { + filters: DataTypes.JSON, + email: DataTypes.STRING, + remove_code: DataTypes.STRING, + type: { + type: DataTypes.ENUM, + values: ['mail', 'admin_email', 'mastodon'] + } + }, {}) + notification.associate = function (models) { + notification.belongsToMany(models.event, { through: 'event_notification' }) + // associations can be defined here + } + return notification +} diff --git a/server/api/models/place.js b/server/api/models/place.js new file mode 100644 index 00000000..a45e9704 --- /dev/null +++ b/server/api/models/place.js @@ -0,0 +1,15 @@ +'use strict' +module.exports = (sequelize, DataTypes) => { + const place = sequelize.define('place', { + name: DataTypes.STRING, + address: DataTypes.STRING, + weigth: DataTypes.INTEGER + }, {}) + + place.associate = function (models) { + // associations can be defined here + place.hasMany(models.event) + } + + return place +} diff --git a/server/api/models/setting.js b/server/api/models/setting.js new file mode 100644 index 00000000..eef0d5a0 --- /dev/null +++ b/server/api/models/setting.js @@ -0,0 +1,14 @@ +'use strict' +module.exports = (sequelize, DataTypes) => { + const setting = sequelize.define('setting', { + key: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + index: true + }, + value: DataTypes.JSON + }, {}) + + return setting +} diff --git a/server/api/models/tag.js b/server/api/models/tag.js new file mode 100644 index 00000000..ba8f9807 --- /dev/null +++ b/server/api/models/tag.js @@ -0,0 +1,19 @@ +'use strict' +module.exports = (sequelize, DataTypes) => { + const tag = sequelize.define('tag', { + tag: { + type: DataTypes.STRING, + index: true, + primaryKey: true + }, + weigth: DataTypes.INTEGER, + color: DataTypes.STRING + }, {}) + + tag.associate = function (models) { + tag.belongsToMany(models.event, { through: 'event_tags' }) + // associations can be defined here + } + + return tag +}; diff --git a/server/api/models/user.js b/server/api/models/user.js new file mode 100644 index 00000000..dff5f86b --- /dev/null +++ b/server/api/models/user.js @@ -0,0 +1,43 @@ +'use strict' +const bcrypt = require('bcrypt') + +module.exports = (sequelize, DataTypes) => { + const user = sequelize.define('user', { + email: { + type: DataTypes.STRING, + unique: true, + index: true, + allowNull: false + }, + description: DataTypes.TEXT, + password: DataTypes.STRING, + recover_code: DataTypes.STRING, + is_admin: DataTypes.BOOLEAN, + is_active: DataTypes.BOOLEAN + }, { + defaultScope: { + attributes: { exclude: ['password', 'recover_code'] } + } + }) + + user.associate = function (models) { + // associations can be defined here + user.hasMany(models.event) + } + + user.prototype.comparePassword = async function (pwd) { + if (!this.password) return false + const ret = await bcrypt.compare(pwd, this.password) + return ret + } + + user.beforeSave(async (user, options) => { + if (user.changed('password')) { + const salt = await bcrypt.genSalt(10) + const hash = await bcrypt.hash(user.password, salt) + user.password = hash + } + }) + + return user +}; diff --git a/server/api/storage.js b/server/api/storage.js new file mode 100644 index 00000000..0fba8074 --- /dev/null +++ b/server/api/storage.js @@ -0,0 +1,62 @@ +const fs = require('fs') +const os = require('os') +const path = require('path') +const crypto = require('crypto') +const mkdirp = require('mkdirp') +const sharp = require('sharp') + +function getDestination(req, file, cb) { + cb(null, os.tmpdir()) +} + +function DiskStorage(opts) { + if (typeof opts.destination === 'string') { + mkdirp.sync(opts.destination) + this.getDestination = function ($0, $1, cb) { cb(null, opts.destination) } + } else { + this.getDestination = (opts.destination || getDestination) + } +} + +DiskStorage.prototype._handleFile = function _handleFile(req, file, cb) { + const that = this + that.getDestination(req, file, function (err, destination) { + if (err) return cb(err) + + const filename = crypto.randomBytes(16).toString('hex') + '.jpg' + const finalPath = path.join(destination, filename) + const thumbPath = path.join(destination, 'thumb', filename) + const outStream = fs.createWriteStream(finalPath) + const thumbStream = fs.createWriteStream(thumbPath) + const resizer = sharp().resize(800).jpeg({ quality: 90 }) + const thumbnailer = sharp().resize(400).jpeg({ quality: 90 }) + + file.stream.pipe(thumbnailer).pipe(thumbStream) + thumbStream.on('error', e => console.log('thumbStream error ', e)) + + file.stream.pipe(resizer).pipe(outStream) + outStream.on('error', cb) + outStream.on('finish', function () { + cb(null, { + destination, + filename, + path: finalPath, + size: outStream.bytesWritten + }) + }) + }) +} + +DiskStorage.prototype._removeFile = function _removeFile(req, file, cb) { + let path = file.path + + delete file.destination + delete file.filename + delete file.path + + fs.unlink(path, cb) +} + +module.exports = function (opts) { + return new DiskStorage(opts) +} diff --git a/server/api/views/feed/rss.pug b/server/api/views/feed/rss.pug new file mode 100644 index 00000000..26429080 --- /dev/null +++ b/server/api/views/feed/rss.pug @@ -0,0 +1,23 @@ +doctype xml +rss(version='2.0') + channel + title #{config.title} + link #{config.baseurl} + description #{config.description} + language #{config.locale} + //- if events.length + lastBuildDate= new Date(posts[0].publishedAt).toUTCString() + each event in events + item + title= event.title + link #{config.baseurl}/event/#{event.id} + description + | #{event.title} + | #{event.place.name} - #{event.place.address} + | #{moment(event.start_datetime).format("dddd, D MMMM HH:mm")}
    + | + |
    !{event.description}
    + | ]]> + pubDate= new Date(event.createdAt).toUTCString() + guid(isPermaLink='false') #{config.baseurl}/event/#{event.id} \ No newline at end of file diff --git a/server/emails/confirm/html.pug b/server/emails/confirm/html.pug new file mode 100644 index 00000000..e0f26aa1 --- /dev/null +++ b/server/emails/confirm/html.pug @@ -0,0 +1,4 @@ +p= t('confirm_email') + +hr +small #{config.baseurl} diff --git a/server/emails/event/html.pug b/server/emails/event/html.pug new file mode 100644 index 00000000..2fefccc6 --- /dev/null +++ b/server/emails/event/html.pug @@ -0,0 +1,18 @@ +h3 #{event.title} +p Dove: #{event.place.name} - #{event.place.address} +p Quando: #{datetime(event.start_datetime)} +br +if event.image_path + +p #{event.description} + +each tag in event.tags + span ##{tag.tag} +br +#{config.baseurl}/event/#{event.id} +hr +if to_confirm + p Puoi confermare questo evento qui +else + p Puoi eliminare queste notifiche qui +#{config.title} - #{config.description} diff --git a/server/emails/event/subject.pug b/server/emails/event/subject.pug new file mode 100644 index 00000000..fd159324 --- /dev/null +++ b/server/emails/event/subject.pug @@ -0,0 +1 @@ += `[${config.title}] ${event.title} @${event.place.name} ${datetime(event.start_datetime)}` diff --git a/server/emails/mail.css b/server/emails/mail.css new file mode 100644 index 00000000..6dd4fb48 --- /dev/null +++ b/server/emails/mail.css @@ -0,0 +1,8 @@ +table { + width: 100%; + border-collapse: collapse; +} + +table, th, td { + border: 1px solid #555; +} diff --git a/server/emails/recover/html.pug b/server/emails/recover/html.pug new file mode 100644 index 00000000..0ef71822 --- /dev/null +++ b/server/emails/recover/html.pug @@ -0,0 +1,3 @@ +p= t('mail.recover') + +#{t('press here')} \ No newline at end of file diff --git a/server/emails/recover/subject.pug b/server/emails/recover/subject.pug new file mode 100644 index 00000000..6d0067a6 --- /dev/null +++ b/server/emails/recover/subject.pug @@ -0,0 +1 @@ += `[Gancio] Richiesta password recovery` diff --git a/server/emails/register/html.pug b/server/emails/register/html.pug new file mode 100644 index 00000000..8cdb9def --- /dev/null +++ b/server/emails/register/html.pug @@ -0,0 +1,6 @@ +p= t('registration_email') + +hr +small #{config.title} / #{config.description} +br +small #{config.baseurl} \ No newline at end of file diff --git a/server/emails/register/subject.pug b/server/emails/register/subject.pug new file mode 100644 index 00000000..88d6807a --- /dev/null +++ b/server/emails/register/subject.pug @@ -0,0 +1 @@ +| [Gancio] #{t('register.request')} diff --git a/server/emails/register/text.pug b/server/emails/register/text.pug new file mode 100644 index 00000000..1a72986e --- /dev/null +++ b/server/emails/register/text.pug @@ -0,0 +1 @@ +| dioicane ciao \ No newline at end of file diff --git a/server/firstrun.js b/server/firstrun.js new file mode 100644 index 00000000..502912dc --- /dev/null +++ b/server/firstrun.js @@ -0,0 +1,41 @@ +// check config.js existance +const fs = require('fs') +const path = require('path') +const argv = require('yargs').argv + +const config_path = path.resolve(argv.config || './config.js') + +if (!fs.existsSync(config_path)) { + console.error(`Configuration file not found at '${config_path}. Please copy 'config.example.js' and modify it.`) + process.exit(1) +} + +const { SECRET_CONF, SHARED_CONF } = require(config_path) +if (!SECRET_CONF.secret) { + console.error(`Please specify a random 'secret' in '${config_path}'!`) + process.exit(1) +} + +const Sequelize = require('sequelize') +let db +try { + db = new Sequelize(SECRET_CONF.db) +} catch (e) { + console.error(`DB Error: check '${SHARED_CONF.env}' configuration.\n (sequelize error -> ${e})`) + process.exit(1) +} + +// return db existence +module.exports = db.authenticate() + .then(() => { + require('./api/models') + if (SHARED_CONF.env === 'development') { + console.error('DB Force sync') + return db.sync({ force: true }) + } + }) + .catch(e => { + console.error(e) + console.error(`DB Error: check '${SHARED_CONF.env}' configuration\n (sequelize error -> ${e})`) + process.exit(1) + }) diff --git a/server/index.js b/server/index.js new file mode 100644 index 00000000..d205da28 --- /dev/null +++ b/server/index.js @@ -0,0 +1,39 @@ +#!/bin/env node +const path = require('path') +const express = require('express') +const consola = require('consola') +const morgan = require('morgan') +const { Nuxt, Builder } = require('nuxt') +const firstRun = require('./firstrun') +// Import and Set Nuxt.js options +const config = require('../nuxt.config.js') + +const app = express() +async function start() { + // Init Nuxt.js + const nuxt = new Nuxt(config) + + const { host, port } = nuxt.options.server + + // Build only in dev mode + if (config.dev) { + const builder = new Builder(nuxt) + await builder.build() + } else { + await nuxt.ready() + } + + // Give nuxt middleware to express + app.use(morgan('dev')) + app.use('/media/', express.static(path.join(__dirname, '..', 'uploads'))) + app.use(nuxt.render) + + // Listen the server + app.listen(port, host) + consola.ready({ + message: `Server listening on http://${host}:${port}`, + badge: true + }) +} + +firstRun.then(start) diff --git a/server/migrations/20190605141112-create-user.js b/server/migrations/20190605141112-create-user.js new file mode 100644 index 00000000..9f10631b --- /dev/null +++ b/server/migrations/20190605141112-create-user.js @@ -0,0 +1,45 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('users', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + email: { + type: Sequelize.STRING, + unique: true, + index: true, + allowNull: false + }, + description: { + type: Sequelize.TEXT + }, + password: { + type: Sequelize.STRING + }, + recover_code: { + type: Sequelize.STRING + }, + is_admin: { + type: Sequelize.BOOLEAN + }, + is_active: { + type: Sequelize.BOOLEAN + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('users'); + } +}; \ No newline at end of file diff --git a/server/migrations/20190605141800-create-place.js b/server/migrations/20190605141800-create-place.js new file mode 100644 index 00000000..ee73e440 --- /dev/null +++ b/server/migrations/20190605141800-create-place.js @@ -0,0 +1,33 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('places', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + name: { + type: Sequelize.STRING + }, + address: { + type: Sequelize.STRING + }, + weigth: { + type: Sequelize.INTEGER + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('places'); + } +}; \ No newline at end of file diff --git a/server/migrations/20190605141850-create-event.js b/server/migrations/20190605141850-create-event.js new file mode 100644 index 00000000..2db6ee51 --- /dev/null +++ b/server/migrations/20190605141850-create-event.js @@ -0,0 +1,66 @@ +'use strict' +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('events', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + title: { + type: Sequelize.STRING + }, + slug: { + type: Sequelize.STRING, + index: true + }, + description: { + type: Sequelize.TEXT + }, + multidate: { + type: Sequelize.BOOLEAN + }, + start_datetime: { + type: Sequelize.DATE + }, + end_datetime: { + type: Sequelize.DATE + }, + image_path: { + type: Sequelize.STRING + }, + is_visible: { + type: Sequelize.BOOLEAN + }, + activitypub_id: { + type: Sequelize.BIGINT + }, + userId: { + type: Sequelize.INTEGER, + references: { + model: 'users', + key: 'id' + } + }, + placeId: { + type: Sequelize.INTEGER, + references: { + model: 'places', + key: 'id' + } + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }) + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('events') + } +} diff --git a/server/migrations/20190605142152-create-notification.js b/server/migrations/20190605142152-create-notification.js new file mode 100644 index 00000000..7ddd5129 --- /dev/null +++ b/server/migrations/20190605142152-create-notification.js @@ -0,0 +1,37 @@ +'use strict' +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('notifications', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + filters: { + type: Sequelize.JSON + }, + email: { + type: Sequelize.STRING + }, + remove_code: { + type: Sequelize.STRING + }, + type: { + type: Sequelize.ENUM, + values: ['mail', 'admin_email', 'mastodon'] + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }) + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('notifications') + } +} diff --git a/server/migrations/20190605142317-create-tag.js b/server/migrations/20190605142317-create-tag.js new file mode 100644 index 00000000..82271aaf --- /dev/null +++ b/server/migrations/20190605142317-create-tag.js @@ -0,0 +1,29 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('tags', { + tag: { + type: Sequelize.STRING, + allowNull: false, + primaryKey: true + }, + weigth: { + type: Sequelize.INTEGER + }, + color: { + type: Sequelize.STRING + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('tags'); + } +}; \ No newline at end of file diff --git a/server/migrations/20190605142409-create-event-notification.js b/server/migrations/20190605142409-create-event-notification.js new file mode 100644 index 00000000..5cebe1e4 --- /dev/null +++ b/server/migrations/20190605142409-create-event-notification.js @@ -0,0 +1,37 @@ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('event_notification', { + eventId: { + type: Sequelize.INTEGER, + references: { + model: 'events', + key: 'id' + } + }, + notificationId: { + type: Sequelize.INTEGER, + references: { + model: 'notifications', + key: 'id' + } + }, + status: { + type: Sequelize.ENUM, + values: ['new', 'sent', 'error'], + defaultValue: 'new', + index: true + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }) + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('event_notification') + } +} diff --git a/server/migrations/20190605142434-create-comment.js b/server/migrations/20190605142434-create-comment.js new file mode 100644 index 00000000..120e0a62 --- /dev/null +++ b/server/migrations/20190605142434-create-comment.js @@ -0,0 +1,38 @@ +'use strict'; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('comments', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + eventId: { + type: Sequelize.INTEGER, + references: { + model: 'events', + key: 'id' + } + }, + activitypub_id: { + type: Sequelize.BIGINT, + index: true, + }, + data: { + type: Sequelize.JSON + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('comments'); + } +}; \ No newline at end of file diff --git a/server/migrations/20190605142619-create-setting.js b/server/migrations/20190605142619-create-setting.js new file mode 100644 index 00000000..fc95a51d --- /dev/null +++ b/server/migrations/20190605142619-create-setting.js @@ -0,0 +1,27 @@ +'use strict' +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('settings', { + key: { + type: Sequelize.STRING, + primaryKey: true, + allowNull: false, + index: true + }, + value: { + type: Sequelize.JSON + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }) + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('settings') + } +} diff --git a/server/migrations/20190605160024-create-event-tag.js b/server/migrations/20190605160024-create-event-tag.js new file mode 100644 index 00000000..c3c904ef --- /dev/null +++ b/server/migrations/20190605160024-create-event-tag.js @@ -0,0 +1,32 @@ +'use strict' +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('event_tags', { + eventId: { + type: Sequelize.INTEGER, + references: { + model: 'events', + key: 'id' + } + }, + tagTag: { + type: Sequelize.STRING, + references: { + model: 'tags', + key: 'tag' + } + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }) + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable('event_tags') + } +} diff --git a/server/notifier.js b/server/notifier.js new file mode 100644 index 00000000..24cb1075 --- /dev/null +++ b/server/notifier.js @@ -0,0 +1,66 @@ +// const mail = require('./mail') +const bot = require('./api/controller/bot') +const settingsController = require('./api/controller/settings') +const config = require('./config.js') + +const { Event, Notification, EventNotification, + User, Place, Tag } = require('./api/models') +let settings + +async function sendNotification(notification, event, eventNotification) { + const promises = [] + switch (notification.type) { + // case 'mail': + // return mail.send(notification.email, 'event', { event, config, notification }) + // case 'admin_email': + // const admins = await User.findAll({ where: { is_admin: true } }) + // const admin_emails = admins.map(admin => admin.email) + // return mail.send(admin_emails, 'event', { event, to_confirm: true, notification }) + case 'mastodon': + // instance publish + if (settings.mastodon_auth.instance && settings.mastodon_auth.access_token) { + const b = bot.post(settings.mastodon_auth, event).then(b => { + event.activitypub_id = b.data.id + // event.activitypub_ids.push(b.data.id) + return event.save() + }) + promises.push(b) + } + } + return Promise.all(promises) +} + +async function notify() { + console.error('dentro il loop di notify') + settings = await settingsController.settings() + // get all event notification in queue + const eventNotifications = await EventNotification.findAll({ where: { status: 'new' } }) + const promises = eventNotifications.map(async e => { + const event = await Event.findByPk(e.eventId, { include: [User, Place, Tag] }) + if (!event.place) return + const notification = await Notification.findByPk(e.notificationId) + try { + await sendNotification(notification, event, e) + e.status = 'sent' + return e.save() + } catch (err) { + console.error(err) + e.status = 'error' + return e.save() + } + }) + + return Promise.all(promises) +} + +let interval +function startLoop(seconds) { + console.error('starting notifier loop') + interval = setInterval(notify, seconds * 1000) +} + +function stopLoop() { + stopInterval(interval) +} + +module.exports = { startLoop, stopLoop } diff --git a/static/README.md b/static/README.md new file mode 100644 index 00000000..cf004353 --- /dev/null +++ b/static/README.md @@ -0,0 +1,11 @@ +# STATIC + +**This directory is not required, you can delete it if you don't want to use it.** + +This directory contains your static files. +Each file inside this directory is mapped to `/`. +Thus you'd want to delete this README.md before deploying to production. + +Example: `/static/robots.txt` is mapped as `/robots.txt`. + +More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#static). diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 00000000..36b55d4a Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/gancio.png b/static/gancio.png new file mode 100644 index 00000000..ef88fd64 Binary files /dev/null and b/static/gancio.png differ diff --git a/store/README.md b/store/README.md new file mode 100644 index 00000000..1972d277 --- /dev/null +++ b/store/README.md @@ -0,0 +1,10 @@ +# STORE + +**This directory is not required, you can delete it if you don't want to use it.** + +This directory contains your Vuex Store files. +Vuex Store option is implemented in the Nuxt.js framework. + +Creating a file in this directory automatically activates the option in the framework. + +More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store). diff --git a/store/index.js b/store/index.js new file mode 100644 index 00000000..48e082d2 --- /dev/null +++ b/store/index.js @@ -0,0 +1,143 @@ +import moment from 'dayjs' +import intersection from 'lodash/intersection' +import map from 'lodash/map' + +export const state = () => ({ + config: {}, + events: [], + tags: [], + places: [], + settings: {}, + filters: { + tags: [], + places: [], + show_past_events: false, + show_recurrent_events: false, + show_pinned_event: false + } +}) + +export const getters = { + + // filter current + future events only + // plus, filter matches search tag/place + filteredEvents: (state) => { + let events = state.events + + // TOFIX: use lodash + if (state.filters.tags.length || state.filters.places.length) { + events = events.filter((e) => { + if (state.filters.tags.length) { + const m = intersection(e.tags.map(t => t.tag), state.filters.tags) + if (m.length > 0) return true + } + if (state.filters.places.length) { + if (state.filters.places.find(p => p === e.place.id)) { + return true + } + } + return 0 + }) + } + + if (!state.show_past_events) { + events = events.filter(e => !e.past) + } + + let lastDay = null + events = map(events, e => { + const currentDay = moment(e.start_datetime).date() + e.newDay = (!lastDay || lastDay !== currentDay) && currentDay + lastDay = currentDay + return e + }) + + return events + } +} + +export const mutations = { + setEvents(state, events) { + // set a `past` flag + state.events = events.map((e) => { + const end_datetime = e.end_datetime || moment(e.start_datetime).add('3', 'hour') + const past = (moment().diff(end_datetime, 'minutes') > 0) + e.past = !!past + return e + }) + }, + addEvent(state, event) { + state.events.push(event) + }, + updateEvent(state, event) { + state.events = state.events.map((e) => { + if (e.id !== event.id) return e + return event + }) + }, + delEvent(state, eventId) { + state.events = state.events.filter(ev => { + return ev.id !== eventId + }) + }, + update(state, { tags, places }) { + state.tags = tags + state.places = places + }, + setSearchTags(state, tags) { + state.filters.tags = tags + }, + setSearchPlaces(state, places) { + state.filters.places = places + }, + showPastEvents(state, show) { + state.show_past_events = show + }, + setSettings(state, settings) { + state.settings = settings + }, + setConfig(state, config) { + state.config = config + } +} + +export const actions = { + async updateEvents({ commit }, page) { + const events = await this.$axios.$get(`/event/${page.month - 1}/${page.year}`) + commit('setEvents', events) + }, + async updateMeta({ commit }) { + const { tags, places } = await this.$axios.$get('/event/meta') + commit('update', { tags, places }) + }, + async addEvent({ commit }, formData) { + const event = await this.$axios.$post('/user/event', formData) + if (event.user) { + commit('addEvent', event) + } + }, + async updateEvent({ commit }, formData) { + const event = await this.$axios.$put('/user/event', formData) + if (event.user) { + commit('updateEvent', event) + } + }, + delEvent({ commit }, eventId) { + commit('delEvent', eventId) + }, + setSearchTags({ commit }, tags) { + commit('setSearchTags', tags) + }, + setSearchPlaces({ commit }, places) { + commit('setSearchPlaces', places) + }, + showPastEvents({ commit }, show) { + commit('showPastEvents', show) + }, + setSettings({ commit }, settings) { + commit('setSettings', settings) + }, + setConfig({ commit }, config) { + commit('setConfig', config) + } +}