diff --git a/.eslintrc b/.eslintrc index 1b07e26..6a1e87f 100644 --- a/.eslintrc +++ b/.eslintrc @@ -7,16 +7,12 @@ } }, "extends": "eslint:recommended", - "plugins": [ - "mocha" - ], "env": { "mocha": true, "node": true, "es6": true }, "rules": { - "mocha/no-exclusive-tests": 2, "require-await": 0, "array-callback-return": 2, "block-scoped-var": 2, @@ -46,7 +42,7 @@ "no-catch-shadow": 2, "no-shadow": 2, "no-undef-init": 2, - "no-undefined": 0, + "no-undefined": 2, "no-use-before-define": 2, "no-new-require": 2, "no-sync": 2, diff --git a/.gitignore b/.gitignore index ca23d9b..1827ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -60,5 +60,7 @@ typings/ # Local development config file config/config.json package-lock.json -public/* +public/assets/app.js +public/assets/app.css +public/assets/app.css.map diff --git a/api/access/middleware.mjs b/api/access/middleware.mjs index b058f90..055d715 100644 --- a/api/access/middleware.mjs +++ b/api/access/middleware.mjs @@ -18,7 +18,7 @@ export function restrict(level = orgAccess.Normal) { return ctx.throw(403, 'Authentication token was not found (did you forget to login?)') } - if (!ctx.state.user || !ctx.state.user.id || !ctx.state.user.level) { + if (!ctx.state.user || !ctx.state.user.email || !ctx.state.user.level) { return ctx.throw(403, 'You must be authenticated to access this resource') } diff --git a/api/article/model.mjs b/api/article/model.mjs new file mode 100644 index 0000000..e051820 --- /dev/null +++ b/api/article/model.mjs @@ -0,0 +1,51 @@ +import bookshelf from '../bookshelf.mjs' +import Media from '../media/model.mjs' +import Staff from '../staff/model.mjs' +import Page from '../page/model.mjs' + +/* + +Article model: +{ + name, + path, + description, + media_id, + staff_id, + parent_id, + is_deleted, + created_at, + updated_at, +} + +*/ + +const Article = bookshelf.createModel({ + tableName: 'articles', + + parent() { + return this.belongsTo(Page, 'parent_id') + }, + + banner() { + return this.belongsTo(Media, 'banner_id') + }, + + media() { + return this.belongsTo(Media, 'media_id') + }, + + staff() { + return this.belongsTo(Staff, 'staff_id') + }, +}, { + getSingle(id, withRelated = [], require = true, ctx = null) { + return this.query(qb => { + qb.where({ id: Number(id) || 0 }) + .orWhere({ path: id }) + }) + .fetch({ require, withRelated, ctx }) + }, +}) + +export default Article diff --git a/api/article/routes.mjs b/api/article/routes.mjs new file mode 100644 index 0000000..7015db5 --- /dev/null +++ b/api/article/routes.mjs @@ -0,0 +1,61 @@ +import Article from './model.mjs' +import * as security from './security.mjs' + +export default class ArticleRoutes { + constructor(opts = {}) { + Object.assign(this, { + Article: opts.Article || Article, + security: opts.security || security, + }) + } + + /** GET: /api/articles */ + async getAllArticles(ctx) { + await this.security.ensureIncludes(ctx) + + let filter = {} + if (ctx.query.tree && ctx.query.tree === 'true') { + filter.parent_id = null + } + + ctx.body = await this.Article.getAll(ctx, filter, ctx.state.filter.includes) + } + + /** GET: /api/articles/:id */ + async getSingleArticle(ctx) { + await this.security.ensureIncludes(ctx) + + ctx.body = await this.Article.getSingle(ctx.params.id, ctx.state.filter.includes, true, ctx) + } + + /** POST: /api/articles */ + async createArticle(ctx) { + await this.security.validUpdate(ctx) + + ctx.body = await this.Article.create(ctx.request.body) + } + + /** PUT: /api/articles/:id */ + async updateArticle(ctx) { + await this.security.validUpdate(ctx) + + let page = await this.Article.getSingle(ctx.params.id) + + page.set(ctx.request.body) + + await page.save() + + ctx.body = page + } + + /** DELETE: /api/articles/:id */ + async removeArticle(ctx) { + let page = await this.Article.getSingle(ctx.params.id) + + page.set({ is_deleted: true }) + + await page.save() + + ctx.status = 204 + } +} diff --git a/api/article/security.mjs b/api/article/security.mjs new file mode 100644 index 0000000..2e81824 --- /dev/null +++ b/api/article/security.mjs @@ -0,0 +1,37 @@ +import filter from '../filter.mjs' + +const requiredFields = [ + 'name', + 'path', +] + +const validFields = [ + 'name', + 'path', + 'description', + 'parent_id', + 'media_id', + 'banner_id', +] + +export async function ensureIncludes(ctx) { + let out = filter(ctx.state.filter.includes, ['staff', 'media', 'parent', 'banner']) + + if (out.length > 0) { + ctx.throw(422, `Includes had following invalid values: ${out.join(', ')}`) + } +} + +export async function validUpdate(ctx) { + requiredFields.forEach(item => { + if (ctx.request.body[item] == null) { + ctx.throw(422, `Property was missing: ${item}`) + } + }) + + let out = filter(Object.keys(ctx.request.body), validFields) + + if (out.length > 0) { + ctx.throw(422, `Body had following invalid properties: ${out.join(', ')}`) + } +} diff --git a/api/authentication/google.mjs b/api/authentication/google.mjs new file mode 100644 index 0000000..aa7404d --- /dev/null +++ b/api/authentication/google.mjs @@ -0,0 +1,14 @@ +import google from 'googleapis' +import googleauth from 'google-auth-library' +import config from '../config' + +const oauth2Client = new googleauth.OAuth2Client(config.get('googleid')) + +// This is hard to have always running as it requires a +// test access token which always expire. + +/* istanbul ignore next */ +export function getProfile(token) { + return oauth2Client.getTokenInfo(token) +} + \ No newline at end of file diff --git a/api/authentication/routes.mjs b/api/authentication/routes.mjs new file mode 100644 index 0000000..41ea90f --- /dev/null +++ b/api/authentication/routes.mjs @@ -0,0 +1,39 @@ +import Staff from '../staff/model' +import Jwt from '../jwt' +import * as google from './google' + +export default class AuthRoutes { + constructor(opts = {}) { + Object.assign(this, { + Staff: opts.Staff || Staff, + jwt: opts.jwt || new Jwt(), + google: opts.google || google, + }) + } + + /* + * POST /api/login - Authenticate a user using social login + * + * @body {string} token - The google token to authenticate + * @returns + * + * { token: 'Authentication token' } + */ + async login(ctx) { + let output = await google.getProfile(ctx.request.body.token) + + if (output.email_verified !== 'true') ctx.throw(422, 'Email was not verified with google') + if (!output.email) ctx.throw(422, 'Email was missing from google response') + + let level = 1 + let staff = await this.Staff + .query({ where: { email: output.email }}) + .fetch({ require: false }) + + if (staff && staff.id && staff.get('level')) { + level = staff.get('level') + } + + ctx.body = { token: this.jwt.createToken(output.email, level) } + } +} diff --git a/api/bookshelf.mjs b/api/bookshelf.mjs index 1527c7b..43915f2 100644 --- a/api/bookshelf.mjs +++ b/api/bookshelf.mjs @@ -6,11 +6,230 @@ import config from './config' import defaults from './defaults' import log from './log' -let host = config.get('knex:connection') +let connections = [config.get('knex:connection')] -log.info(host, 'Connecting to DB') +if (config.get('knex:connectionslave')) { + connections.push(config.get('knex:connectionslave')) +} -const client = knex(config.get('knex')) +let isRecovering = false +let isUrgent = false +let currentIndex = 0 +let nextIndex = currentIndex + 1 +let client +let secondaryClient + +/** + * Semi-gracefully shift the current active client connection from the + * current connected client and switch to the selected index server. + */ +async function shiftConnection(index) { + // Update our variables + isUrgent = false + currentIndex = index + + log.warn('DB: Destroying current pool') + await client.destroy() + + // Update connection settings to the new server and re-initialize the pool. + log.warn(connections[currentIndex], 'DB: Connecting to next server') + client.client.connectionSettings = connections[currentIndex] + client.initialize() +} + +/** + * Start a graceful server migration. Creates a secondary database connection + * and checks other available servers we have if they're up and can be used. + */ +async function gracefulServerMigrate() { + // Check if we're already recovering and exit then. + if (isRecovering) return + + // Urgent means we don't have ANY active database connectiong and need one quickly. + if (isUrgent) { + log.error(connections[currentIndex], `DB: Server connected to is offline.`) + } else { + log.warn(connections[currentIndex], `DB: Successfully connected to a server but its status was recovering (slave).`) + } + log.warn('DB: Attempting to gracefully connecting to different server') + isRecovering = true + + // Load up next server into a new knex connection and start connecting. + if (nextIndex === connections.length) { + nextIndex = 0 + } + secondaryClient = knex(getConfig(nextIndex, false)) + + // Keep on trying :) + while (true) { + // Make multiple attempts when we're connecting to downed or timed out databases. + let attempts = 0 + + while (attempts++ < 5) { + try { + log.warn(connections[nextIndex], `DB: Gracefully attempting to connect to server (attempt ${attempts}/5).`) + + // Connect to the database (this creates a new pool connection) and check if it's in recovery mode + let data = await secondaryClient.raw('select pg_is_in_recovery()') + + // If we reach here, we got data which means the database is up and running. + // As such, there's no need to make more attempts to same server + attempts = 6 + + // Check if it's master or if we are desperate + if (!data.rows[0].pg_is_in_recovery || isUrgent) { + // Found a viable server to connect to. Shift our active client to it. + log.info(connections[nextIndex], 'DB: Found available server, connecting to it') + await shiftConnection(nextIndex) + + // Check if we're connected to master or just a slave. + if (!data.rows[0].pg_is_in_recovery) { + // We found a master, stop recovering + log.info(connections[nextIndex], 'DB: Connection established with master.') + isRecovering = false + break + } + } + } catch (err) { + // We only care to log weird errors like postgresql errors or such. + if (err.code !== 'ECONNREFUSED' && err.code !== 'ETIMEDOUT') { + log.error({ code: err.code, message: err.message }, `DB: Unknown error while gracefully connecting to ${connections[nextIndex].host}`) + } + + // Make a next attempt after 10 seconds + await new Promise(res => setTimeout(res, 10000)) + } + } + + // Check if we found a master and break if we did. + if (isRecovering === false) break + + // Didn't find a master :( wait 60 seconds before running another attempt + log.warn(connections[nextIndex], 'DB: Connected server was deemeed unable to fit master role') + log.warn('DB: waiting 60 seconds before attempting next server') + + await new Promise(res => setTimeout(res, 60000)) + + // Move to next server + nextIndex++ + if (nextIndex === connections.length) { + nextIndex = 0 + } + + // Time to destroy our active pool on our current server and update + // the connection settings to the next server and re-initialise. + await secondaryClient.destroy() + secondaryClient.client.connectionSettings = connections[nextIndex] + secondaryClient.initialize() + } + + // We got here means we have stopped recovery process. + // Shut down the secondary knex client and destroy it and + // remove reference to it so GC can collect it eventually, hopefully. + await secondaryClient.destroy() + nextIndex = currentIndex + 1 + secondaryClient = null +} + +/** + * Event handler after our pool is created and we are creating a connection. + * Here we check if the database is in recovery mode (a.k.a. slave) and if so + * start the graceful migration to migrate back to master once it's up and running. + */ +function afterCreate(conn, done) { + conn.query('select pg_is_in_recovery()', (e, res) => { + if (e) return done(e, conn) + if (res.rows[0].pg_is_in_recovery) gracefulServerMigrate().then() + done(null, conn) + }) +} + +/** + * Event handler for when the pool gets destroyed. Here we check + * if the connection has been marked with _ending = true. + * There are some checks available we can use to check if current + * connection was abrubtly disconnected. Among those from my testing + * are as follows: + * + * conn.__knex__disposed = 'Connection ended unexpectedly' + * conn.connection._ending = true + * + * I went with connection._ending one as I feel that one's the safest. + * + */ +function beforeDestroy(conn) { + if (conn.connection._ending) { + checkActiveConnection() + } +} + +/** + * Return a valid confic for knex based on specific connection index. + * Note that we don't wanna hook into afterCreate or beforeDestroy + * in our secondary knex connection doing the recovery checking. + */ +function getConfig(index = 0, addEvents = true) { + return { + 'client': 'pg', + 'connection': connections[index], + 'migrations': { + }, + pool: { + afterCreate: addEvents && afterCreate || null, + min: 2, + max: 10, + beforeDestroy: addEvents && beforeDestroy || null, + }, + acquireConnectionTimeout: 10000, + } +} + +client = knex(getConfig(currentIndex)) + +/** + * Make sure no update or delete queries are run while we're recovering. + * This allows knex to connect to a slave and only process select queries. + * + * Note: Probably does not support complicated select queries that cause + * updates on trigger or other such things. + */ +client.on('query', data => { + if (isRecovering && data.method !== 'select') { + throw new Error('Database is in read-only mode') + } +}) + +function checkActiveConnection(attempt = 1) { + if (attempt > 5) { + isUrgent = true + return gracefulServerMigrate().then() + } + // log.info(`DB: (Attempt ${attempt}/5) Checking connection is active.`) + client.raw('select 1').catch(err => { + if (err.code === 'ECONNREFUSED') { // err.code === 'ETIMEDOUT' + isUrgent = true + return gracefulServerMigrate().then() + } + if (err) { + let wait = 3000 // err.code like '57P03' and such. + if (err.code === 'ETIMEDOUT') { + wait = 10000 + } + + log.error({ code: err.code, message: err.message }, `DB: (Attempt ${attempt}/5) Error while checking connection status`) + if (attempt < 5) { + log.warn(`DB: (Attempt ${attempt}/5) Attempting again in ${wait / 1000} seconds.`) + setTimeout(() => checkActiveConnection(attempt + 1), wait) + } else { + checkActiveConnection(attempt + 1) + } + } + }) +} + +// Only way to check startup connection errors +log.info(getConfig(currentIndex).connection, 'DB: Connecting to server') +setTimeout(() => checkActiveConnection(), 100) // Check if we're running tests while connected to // potential production environment. @@ -122,6 +341,7 @@ shelf.createModel = (attr, opts) => { !options.query._statements[0].column.indexOf || options.query._statements[0].column.indexOf('is_deleted') === -1) { // First override that is_deleted always gets filtered out. + options.query.where(`${collection.tableName()}.is_deleted`, false) } diff --git a/api/file/model.mjs b/api/file/model.mjs new file mode 100644 index 0000000..3c884cf --- /dev/null +++ b/api/file/model.mjs @@ -0,0 +1,26 @@ +import path from 'path' +import bookshelf from '../bookshelf' + +/* + +File model: +{ + filename, + filetype, + size, + staff_id, + article_id, + is_deleted, + created_at, + updated_at, +} + +*/ + +const File = bookshelf.createModel({ + tableName: 'files', +}, { + baseUrl: 'http://192.168.42.14', +}) + +export default File diff --git a/api/file/routes.mjs b/api/file/routes.mjs new file mode 100644 index 0000000..4bcd930 --- /dev/null +++ b/api/file/routes.mjs @@ -0,0 +1,49 @@ +import config from '../config' +import File from './model' +import * as multer from '../multer' +import { uploadFile } from '../media/upload' +import Jwt from '../jwt' + +export default class FileRoutes { + constructor(opts = {}) { + Object.assign(this, { + File: opts.File || File, + multer: opts.multer || multer, + jwt: opts.jwt || new Jwt(), + uploadFile: opts.uploadFile || uploadFile, + }) + } + + async upload(ctx) { + let result = await this.multer.processBody(ctx) + + let token = this.jwt.signDirect({ site: config.get('upload:name') }, config.get('upload:secret')) + + return ctx.throw(422, 'Unable to process for now') + + let file = await this.uploadFile(token, result.path) + ctx.body = await this.File.create({ + filename: result.originalname, + filetype: result.mimetype, + article_id: ctx.params.articleId, + size: result.size, + staff_id: ctx.state.user.id, + }) + } + + async getAllFiles(ctx) { + ctx.body = await this.File.getAll(ctx) + } + + async removeFile(ctx) { + let file = await this.File.getSingle(ctx.params.id) + + file.set({ + is_deleted: true, + }) + + await file.save() + + ctx.status = 200 + } +} diff --git a/api/filter.mjs b/api/filter.mjs new file mode 100644 index 0000000..4613183 --- /dev/null +++ b/api/filter.mjs @@ -0,0 +1,8 @@ + +export default function filter(input = [], itemFilter = []) { + if (input && input.length) { + let out = input.filter(item => item && itemFilter.indexOf(item) < 0) + return out + } + return [] +} diff --git a/api/index/routes.mjs b/api/index/routes.mjs new file mode 100644 index 0000000..3e875f7 --- /dev/null +++ b/api/index/routes.mjs @@ -0,0 +1,12 @@ +import dot from 'dot' +import fs from 'fs' + +export default class IndexRoutes { + constructor(opts = {}) { + this.indexBody = '' + }) + + async sendIndex(ctx) { + ctx.body = this.indexBody + } +} diff --git a/api/jwt.mjs b/api/jwt.mjs index e59fcf9..0cabbaf 100644 --- a/api/jwt.mjs +++ b/api/jwt.mjs @@ -43,23 +43,17 @@ export default class Jwt { return this.jwt.decode(token) } - createStaffToken(staff, opts) { + createToken(email, level, opts) { return this.sign({ - id: staff.id, - level: staff.get('level'), - }, staff.get('password'), opts) - } - - async getUserSecret(header, payload) { - let staff = await this.Staff.getSingle(payload.id) - return staff.id + email: email, + level: level, + }, email, opts) } static jwtMiddleware() { return koaJwt({ secret: (header, payload) => - Staff.getSingle(payload.id) - .then(staff => `${config.get('jwt:secret')}${staff.get('password')}`), + `${config.get('jwt:secret')}${payload.email}`, passthrough: true, }) } diff --git a/api/media/model.mjs b/api/media/model.mjs new file mode 100644 index 0000000..f2eaf69 --- /dev/null +++ b/api/media/model.mjs @@ -0,0 +1,68 @@ +import path from 'path' +import bookshelf from '../bookshelf' + +/* + +Media model: +{ + filename, + filetype, + small_image, + medium_image, + large_image, + *small_url, + *medium_url, + *large_url, + size, + staff_id, + is_deleted, + created_at, + updated_at, +} + +*/ + +const Media = bookshelf.createModel({ + tableName: 'media', + + virtuals: { + small_url() { + return `${Media.baseUrl}${this.get('small_image')}` + }, + + medium_url() { + return `${Media.baseUrl}${this.get('medium_image')}` + }, + + large_url() { + return `${Media.baseUrl}${this.get('large_image')}` + }, + + link() { + return `${Media.baseUrl}${this.get('large_image')}` + }, + + url() { + return `${Media.baseUrl}${this.get('large_image')}` + }, + + thumb() { + return `${Media.baseUrl}${this.get('small_image')}` + }, + }, +}, { + baseUrl: 'http://192.168.42.14', + + getSubUrl(input, size) { + if (!input) return input + + let output = input + if (path.extname(input)) { + let ext = path.extname(input).toLowerCase() + output = input.slice(0, -ext.length) + } + return `${output}.${size}.jpg` + }, +}) + +export default Media diff --git a/api/media/resize.mjs b/api/media/resize.mjs new file mode 100644 index 0000000..6c4963c --- /dev/null +++ b/api/media/resize.mjs @@ -0,0 +1,44 @@ +import sharp from 'sharp' +import Media from './model' + +export default class Resizer { + constructor(opts = {}) { + Object.assign(this, { + Media: opts.Media || Media, + sharp: opts.sharp || sharp, + }) + } + + createSmall(input) { + let output = this.Media.getSubUrl(input, 'small') + + return this.sharp(input) + .resize(360, 360, { + fit: sharp.fit.inside, + withoutEnlargement: true + }) + .toFile(output) + .then(() => output) + } + + createMedium(input) { + let output = this.Media.getSubUrl(input, 'medium') + + return this.sharp(input) + .resize(700, 700, { + fit: sharp.fit.inside, + withoutEnlargement: true + }) + .toFile(output) + .then(() => output) + } + + autoRotate(input) { + const output = `${input}_2.jpg` + + return this.sharp(input) + .rotate() + .toFile(output) + .then(() => output) + } +} diff --git a/api/media/routes.mjs b/api/media/routes.mjs new file mode 100644 index 0000000..8eb886e --- /dev/null +++ b/api/media/routes.mjs @@ -0,0 +1,59 @@ +import config from '../config' +import Media from './model' +import * as multer from '../multer' +import Resizer from './resize' +import { uploadFile } from './upload' +import Jwt from '../jwt' + +export default class MediaRoutes { + constructor(opts = {}) { + Object.assign(this, { + Media: opts.Media || Media, + multer: opts.multer || multer, + resize: opts.resize || new Resizer(), + jwt: opts.jwt || new Jwt(), + uploadFile: opts.uploadFile || uploadFile, + }) + } + + async upload(ctx) { + let result = await this.multer.processBody(ctx) + + let smallPath = await this.resize.createSmall(result.path) + let mediumPath = await this.resize.createMedium(result.path) + + let token = this.jwt.signDirect({ site: config.get('upload:name') }, config.get('upload:secret')) + + let [large, small, medium] = await Promise.all([ + this.uploadFile(token, result.path), + this.uploadFile(token, smallPath), + this.uploadFile(token, mediumPath), + ]) + + ctx.body = await this.Media.create({ + filename: result.originalname, + filetype: result.mimetype, + small_image: small.path, + medium_image: medium.path, + large_image: large.path, + size: result.size, + staff_id: ctx.state.user.id, + }) + } + + async getAllMedia(ctx) { + ctx.body = await this.Media.getAll(ctx) + } + + async removeMedia(ctx) { + let media = await this.Media.getSingle(ctx.params.id) + + media.set({ + is_deleted: true, + }) + + await media.save() + + ctx.status = 200 + } +} diff --git a/api/media/upload.mjs b/api/media/upload.mjs new file mode 100644 index 0000000..019cec2 --- /dev/null +++ b/api/media/upload.mjs @@ -0,0 +1,77 @@ +import http from 'http' +import path from 'path' +import fs from 'fs' +import config from '../config' + +let stub + +export function uploadFile(token, file) { + // For testing + if (stub) return stub(token, file) + + return new Promise((resolve, reject) => { + fs.readFile(file, (err, data) => { + if (err) return reject(err) + + const crlf = '\r\n' + const filename = path.basename(file) + const boundary = `--${Math.random().toString(16)}` + const headers = [ + `Content-Disposition: form-data; name="file"; filename="${filename}"` + crlf, + ] + const multipartBody = Buffer.concat([ + new Buffer( + `${crlf}--${boundary}${crlf}` + + headers.join('') + crlf + ), + data, + new Buffer( + `${crlf}--${boundary}--` + ), + ]) + + const options = { + port: config.get('upload:port'), + hostname: config.get('upload:host'), + method: 'POST', + path: '/media?token=' + token, + headers: { + 'Content-Type': 'multipart/form-data; boundary=' + boundary, + 'Content-Length': multipartBody.length, + }, + } + + const req = http.request(options) + + req.write(multipartBody) + req.end() + + req.on('error', reject) + + req.on('response', res => { + res.setEncoding('utf8') + let output = '' + + res.on('data', function (chunk) { + output += chunk.toString() + }) + + res.on('end', function () { + try { + output = JSON.parse(output) + } catch (e) { + return reject(e) + } + if (output.status) { + return reject(new Error(`Unable to upload! [${output.status}]: ${output.message}`)) + } + resolve(output) + }) + }) + }) + }) +} + +export function overrideStub(newStub) { + stub = newStub +} diff --git a/api/middlewares/mask.mjs b/api/middlewares/mask.mjs new file mode 100644 index 0000000..a0f1827 --- /dev/null +++ b/api/middlewares/mask.mjs @@ -0,0 +1,18 @@ +import jsonmask from 'json-mask' + +export function mask() { + return async function(ctx, next) { + await next() + + let body = ctx.body + let fields = ctx.query['fields'] || ctx.fields + + if (!body || 'object' != typeof body || !fields) return + + if (body && body.toJSON) { + body = body.toJSON() + } + + ctx.body = jsonmask.filter(body, jsonmask.compile(fields)) + } +} diff --git a/api/multer.mjs b/api/multer.mjs new file mode 100644 index 0000000..43fc168 --- /dev/null +++ b/api/multer.mjs @@ -0,0 +1,15 @@ +import multer from 'multer' + +const storage = multer.diskStorage({ + filename: (req, file, cb) => cb(null, file.originalname), +}) +const upload = multer({ storage: storage }) + +export function processBody(ctx) { + return new Promise((res, rej) => { + upload.single('file')(ctx.req, ctx.res, (err) => { + if (err) return rej(err) + return res(ctx.req.file) + }) + }) +} diff --git a/api/page/model.mjs b/api/page/model.mjs new file mode 100644 index 0000000..480c324 --- /dev/null +++ b/api/page/model.mjs @@ -0,0 +1,66 @@ +import bookshelf from '../bookshelf.mjs' +import Media from '../media/model.mjs' +import Staff from '../staff/model.mjs' +import Article from '../article/model.mjs' + +/* + +Page model: +{ + filename, + filetype, + small_image, + medium_image, + large_image, + *small_url, + *medium_url, + *large_url, + size, + staff_id, + is_deleted, + created_at, + updated_at, +} + +*/ + +const Page = bookshelf.createModel({ + tableName: 'pages', + + banner() { + return this.belongsTo(Media, 'banner_id') + }, + + parent() { + return this.belongsTo(Page, 'parent_id') + }, + + children() { + return this.hasManyFiltered(Page, 'children', 'parent_id') + }, + + news() { + return this.hasManyFiltered(Article, 'news', 'parent_id') + .query(qb => { + qb.orderBy('id', 'desc') + }) + }, + + media() { + return this.belongsTo(Media, 'media_id') + }, + + staff() { + return this.belongsTo(Staff, 'staff_id') + }, +}, { + getSingle(id, withRelated = [], require = true, ctx = null) { + return this.query(qb => { + qb.where({ id: Number(id) || 0 }) + .orWhere({ path: id }) + }) + .fetch({ require, withRelated, ctx }) + }, +}) + +export default Page diff --git a/api/page/routes.mjs b/api/page/routes.mjs new file mode 100644 index 0000000..debdd50 --- /dev/null +++ b/api/page/routes.mjs @@ -0,0 +1,61 @@ +import Page from './model.mjs' +import * as security from './security.mjs' + +export default class PageRoutes { + constructor(opts = {}) { + Object.assign(this, { + Page: opts.Page || Page, + security: opts.security || security, + }) + } + + /** GET: /api/pages */ + async getAllPages(ctx) { + await this.security.ensureIncludes(ctx) + + let filter = {} + if (ctx.query.tree && ctx.query.tree === 'true') { + filter.parent_id = null + } + + ctx.body = await this.Page.getAll(ctx, filter, ctx.state.filter.includes) + } + + /** GET: /api/pages/:id */ + async getSinglePage(ctx) { + await this.security.ensureIncludes(ctx) + + ctx.body = await this.Page.getSingle(ctx.params.id, ctx.state.filter.includes, true, ctx) + } + + /** POST: /api/pages */ + async createPage(ctx) { + await this.security.validUpdate(ctx) + + ctx.body = await this.Page.create(ctx.request.body) + } + + /** PUT: /api/pages/:id */ + async updatePage(ctx) { + await this.security.validUpdate(ctx) + + let page = await this.Page.getSingle(ctx.params.id) + + page.set(ctx.request.body) + + await page.save() + + ctx.body = page + } + + /** DELETE: /api/pages/:id */ + async removePage(ctx) { + let page = await this.Page.getSingle(ctx.params.id) + + page.set({ is_deleted: true }) + + await page.save() + + ctx.status = 204 + } +} diff --git a/api/page/security.mjs b/api/page/security.mjs new file mode 100644 index 0000000..4f8781c --- /dev/null +++ b/api/page/security.mjs @@ -0,0 +1,37 @@ +import filter from '../filter.mjs' + +const requiredFields = [ + 'name', + 'path', +] + +const validFields = [ + 'name', + 'path', + 'parent_id', + 'description', + 'media_id', + 'banner_id', +] + +export async function ensureIncludes(ctx) { + let out = filter(ctx.state.filter.includes, ['staff', 'media', 'banner', 'news', 'news.media', 'parent', 'children']) + + if (out.length > 0) { + ctx.throw(422, `Includes had following invalid values: ${out.join(', ')}`) + } +} + +export async function validUpdate(ctx) { + requiredFields.forEach(item => { + if (ctx.request.body[item] == null) { + ctx.throw(422, `Property was missing: ${item}`) + } + }) + + let out = filter(Object.keys(ctx.request.body), validFields) + + if (out.length > 0) { + ctx.throw(422, `Body had following invalid properties: ${out.join(', ')}`) + } +} diff --git a/api/router.mjs b/api/router.mjs index aa89c79..8154fa9 100644 --- a/api/router.mjs +++ b/api/router.mjs @@ -1,14 +1,44 @@ /* eslint max-len: 0 */ import Router from 'koa-router' -// import access from './access' -// import AuthRoutes from './authentication/routes' -// import { restrict } from './access/middleware' +import access from './access' +import AuthRoutes from './authentication/routes' +import MediaRoutes from './media/routes' +import FileRoutes from './file/routes' +import PageRoutes from './page/routes' +import ArticleRoutes from './article/routes' +import { restrict } from './access/middleware' const router = new Router() // API Authentication -// const authentication = new AuthRoutes() -// router.post('/api/login', authentication.login.bind(authentication)) +const authentication = new AuthRoutes() +router.post('/api/login', authentication.login.bind(authentication)) + +// API Media +const media = new MediaRoutes() +router.get('/api/media', restrict(access.Manager), media.getAllMedia.bind(media)) +router.post('/api/media', restrict(access.Manager), media.upload.bind(media)) +router.del('/api/media/:id', restrict(access.Manager), media.removeMedia.bind(media)) + +// API File +const file = new FileRoutes() +router.get('/api/file', restrict(access.Manager), file.getAllFiles.bind(file)) +router.post('/api/articles/:articleId/file', restrict(access.Manager), file.upload.bind(file)) +router.del('/api/file/:id', restrict(access.Manager), file.removeFile.bind(file)) + +const page = new PageRoutes() +router.get('/api/pages', page.getAllPages.bind(page)) +router.get('/api/pages/:id', page.getSinglePage.bind(page)) +router.post('/api/pages', restrict(access.Manager), page.createPage.bind(page)) +router.put('/api/pages/:id', restrict(access.Manager), page.updatePage.bind(page)) +router.del('/api/pages/:id', restrict(access.Manager), page.removePage.bind(page)) + +const article = new ArticleRoutes() +router.get('/api/articles', article.getAllArticles.bind(article)) +router.get('/api/articles/:id', article.getSingleArticle.bind(article)) +router.post('/api/articles', restrict(access.Manager), article.createArticle.bind(article)) +router.put('/api/articles/:id', restrict(access.Manager), article.updateArticle.bind(article)) +router.del('/api/articles/:id', restrict(access.Manager), article.removeArticle.bind(article)) export default router diff --git a/app/_common.scss b/app/_common.scss new file mode 100644 index 0000000..066a1cc --- /dev/null +++ b/app/_common.scss @@ -0,0 +1,18 @@ +$primary-bg: #01579b; +$primary-fg: white; +$primary-light-bg: #3D77C7; /*#4f83cc;*/ +$primary-light-fg: white; +$primary-dark-bg: #002f6c; +$primary-dark-fg: white; + +$secondary-bg: #f57c00; +$secondary-fg: black; +$secondary-light-bg: #ffad42; +$secondary-light-fg: black; +$secondary-dark-bg: #bb4d00; +$secondary-dark-fg: white; + +$border: #ccc; +$title-fg: #555; +$meta-fg: #757575; /* #999 */ +$meta-light-fg: #999999; diff --git a/app/admin/admin.scss b/app/admin/admin.scss new file mode 100644 index 0000000..1cd8c57 --- /dev/null +++ b/app/admin/admin.scss @@ -0,0 +1,39 @@ + +.admin-wrapper { + flex-grow: 2; + display: flex; + flex-direction: column; + background: $primary-bg; + padding: 0 20px 20px; +} + +.admin-actions { + background: $primary-bg; + display: flex; + justify-content: center; + min-height: 37px; + + span { + color: $primary-fg; + padding: 10px; + font-size: 14px; + font-weight: bold; + } + + a { + padding: 10px; + text-decoration: none; + color: $secondary-light-bg; + font-size: 14px; + font-weight: bold; + } +} + +.fr-box, +.fr-toolbar, +.fr-box .second-toolbar { + border-radius: 0 !important; +} + +@import 'pages'; +@import 'articles'; diff --git a/app/admin/articles.js b/app/admin/articles.js new file mode 100644 index 0000000..b42e854 --- /dev/null +++ b/app/admin/articles.js @@ -0,0 +1,109 @@ +const m = require('mithril') + +const Authentication = require('../authentication') +const { getAllArticles, removeArticle } = require('../api/article') +const Dialogue = require('../widgets/dialogue') + +const AdminArticles = { + oninit: function(vnode) { + this.loading = true + this.error = '' + this.articles = [] + this.removeArticle = null + + getAllArticles() + .then(function(result) { + vnode.state.articles = result + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + confirmRemoveArticle: function(vnode) { + let removingArticle = this.removeArticle + this.removeArticle = null + this.loading = true + removeArticle(removingArticle, removingArticle.id) + .then(this.oninit.bind(this, vnode)) + .catch(function(err) { + vnode.state.error = err.message + vnode.state.loading = false + m.redraw() + }) + }, + + drawArticle: function(vnode, article) { + let parent + if (article.parent) { + parent = { + path: '/page/' + article.parent.path, + name: article.parent.name, + } + } else { + parent = { + path: '/', + name: '-- Frontpage --', + } + } + return [ + m('tr', [ + m('td', m(m.route.Link, { href: '/admin/articles/' + article.id }, article.name)), + m('td', m(m.route.Link, { href: parent.path }, parent.name)), + m('td', m(m.route.Link, { href: '/article/' + article.path }, '/article/' + article.path)), + m('td.right', article.updated_at.replace('T', ' ').split('.')[0]), + m('td.right', m('button', { onclick: function() { vnode.state.removeArticle = article } }, 'Remove')), + ]) + ] + }, + + view: function(vnode) { + return [ + (this.loading ? + m('div.loading-spinner') + : m('div.admin-wrapper', [ + m('div.admin-actions', [ + m('span', 'Actions:'), + m(m.route.Link, { href: '/admin/articles/add' }, 'Create new article'), + ]), + m('article.editarticle', [ + m('header', m('h1', 'All articles')), + m('div.error', { + hidden: !this.error, + onclick: function() { vnode.state.error = '' } + }, this.error), + m('table', [ + m('thead', + m('tr', [ + m('th', 'Title'), + m('th', 'Page'), + m('th', 'Path'), + m('th.right', 'Updated'), + m('th.right', 'Actions'), + ]) + ), + m('tbody', this.articles.map(AdminArticles.drawArticle.bind(this, vnode))), + ]), + ]), + ]) + ), + m(Dialogue, { + hidden: vnode.state.removeArticle === null, + title: 'Delete ' + (vnode.state.removeArticle ? vnode.state.removeArticle.name : ''), + message: 'Are you sure you want to remove "' + (vnode.state.removeArticle ? vnode.state.removeArticle.name : '') + '" (' + (vnode.state.removeArticle ? vnode.state.removeArticle.path : '') + ')', + yes: 'Remove', + yesclass: 'alert', + no: 'Cancel', + noclass: 'cancel', + onyes: this.confirmRemoveArticle.bind(this, vnode), + onno: function() { vnode.state.removeArticle = null }, + }), + ] + }, +} + +module.exports = AdminArticles diff --git a/app/admin/articles.scss b/app/admin/articles.scss new file mode 100644 index 0000000..5a91ee6 --- /dev/null +++ b/app/admin/articles.scss @@ -0,0 +1,87 @@ + +article.editarticle { + text-align: center; + background: white; + padding: 0 0 20px; + + header { + padding: 10px; + background: $secondary-bg; + + h1 { + color: $secondary-fg; + } + + a { + font-size: 14px; + text-decoration: none; + margin-left: 10px; + color: $secondary-light-fg; + } + } + + fileupload { + margin: 0 0 20px; + + .inside { + height: 100px; + } + } + + fileupload.cover { + align-self: center; + min-width: 178px; + } + + form { + padding: 0 40px 20px; + + textarea { + height: 300px; + } + + .loading-spinner { + height: 300px; + position: relative; + } + } + + h5 { + margin-bottom: 20px; + } + + & > .loading-spinner { + width: 240px; + height: 50px; + position: relative; + } + + .fileupload { + align-self: center; + padding: 10.5em; + margin: 0.5em 0; + min-width: 250px; + border: none; + border: 1px solid $secondary-bg; + background: $secondary-light-bg; + color: $secondary-light-fg; + position: relative; + + input { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + opacity: 0.01; + width: 100%; + cursor: pointer; + text-indent: -9999px; + z-index: 2; + } + } +} + +table span.subarticle { + padding: 0 5px; +} diff --git a/app/admin/editarticle.js b/app/admin/editarticle.js new file mode 100644 index 0000000..7a8c8b7 --- /dev/null +++ b/app/admin/editarticle.js @@ -0,0 +1,244 @@ +const m = require('mithril') + +const Authentication = require('../authentication') +const FileUpload = require('../widgets/fileupload') +const Froala = require('./froala') +const { Tree } = require('../api/page') +const { createArticle, updateArticle, getArticle } = require('../api/article') + +const EditArticle = { + getFroalaOptions: function() { + return { + theme: 'gray', + heightMin: 150, + videoUpload: false, + imageUploadURL: '/api/media', + imageManagerLoadURL: '/api/media', + imageManagerDeleteMethod: 'DELETE', + imageManagerDeleteURL: '/api/media', + events: { + 'imageManager.beforeDeleteImage': function(img) { + this.opts.imageManagerDeleteURL = '/api/media/' + img.data('id') + }, + }, + requestHeaders: { + 'Authorization': 'Bearer ' + Authentication.getToken(), + }, + } + }, + + oninit: function(vnode) { + this.loading = m.route.param('key') !== 'add' + this.creating = m.route.param('key') === 'add' + this.loadingFile = false + this.error = '' + this.article = { + name: '', + path: '', + description: '', + media: null, + banner: null, + } + this.editedPath = false + this.froala = null + this.loadedFroala = Froala.loadedFroala + + if (m.route.param('key') !== 'add') { + getArticle(m.route.param('key')) + .then(function(result) { + vnode.state.editedPath = true + vnode.state.article = result + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + } + + if (!this.loadedFroala) { + Froala.createFroalaScript() + .then(function() { + vnode.state.loadedFroala = true + m.redraw() + }) + } + }, + + updateValue: function(name, e) { + this.article[name] = e.currentTarget.value + if (name === 'path') { + this.editedPath = true + } else if (name === 'name' && !this.editedPath) { + this.article.path = this.article.name.toLowerCase().replace(/ /g, '-') + } + }, + + updateParent: function(e) { + this.article.parent_id = Number(e.currentTarget.value) + if (this.article.parent_id === -1) { + this.article.parent_id = null + } + }, + + fileUploaded: function(type, media) { + this.article[type] = media + }, + + save: function(vnode, e) { + e.preventDefault() + if (!this.article.name) { + this.error = 'Name is missing' + } else if (!this.article.path) { + this.error = 'Path is missing' + } + if (this.error) return + + this.article.description = vnode.state.froala && vnode.state.froala.html.get() || this.article.description + + this.loading = true + + let promise + + if (this.article.id) { + promise = updateArticle(this.article.id, { + name: this.article.name, + path: this.article.path, + parent_id: this.article.parent_id, + description: this.article.description, + banner_id: this.article.banner && this.article.banner.id, + media_id: this.article.media && this.article.media.id, + }) + } else { + promise = createArticle({ + name: this.article.name, + path: this.article.path, + parent_id: this.article.parent_id, + description: this.article.description, + banner_id: this.article.banner && this.article.banner.id, + media_id: this.article.media && this.article.media.id, + }) + } + + promise.then(function(res) { + if (vnode.state.article.id) { + res.media = vnode.state.article.media + res.banner = vnode.state.article.banner + vnode.state.article = res + } else { + m.route.set('/admin/articles/' + res.id) + } + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + uploadFile(vnode, event) { + if (!event.target.files[0]) return + vnode.state.error = '' + vnode.state.loadingFile = true + }, + + getFlatTree: function() { + let out = [{id: null, name: '-- Frontpage --'}] + Tree.forEach(function(page) { + out.push({ id: page.id, name: page.name }) + if (page.children.length) { + page.children.forEach(function(sub) { + out.push({ id: sub.id, name: page.name + ' -> ' + sub.name }) + }) + } + }) + return out + }, + + view: function(vnode) { + const parents = this.getFlatTree() + return ( + this.loading ? + m('div.loading-spinner') + : m('div.admin-wrapper', [ + m('div.admin-actions', this.article.id + ? [ + m('span', 'Actions:'), + m(m.route.Link, { href: '/article/' + this.article.path }, 'View article'), + ] + : null), + m('article.editarticle', [ + m('header', m('h1', this.creating ? 'Create Article' : 'Edit ' + (this.article.name || '(untitled)'))), + m('div.error', { + hidden: !this.error, + onclick: function() { vnode.state.error = '' } + }, this.error), + m(FileUpload, { + onupload: this.fileUploaded.bind(this, 'banner'), + onerror: function(e) { vnode.state.error = e }, + media: this.article && this.article.banner, + }), + m(FileUpload, { + class: 'cover', + useimg: true, + onupload: this.fileUploaded.bind(this, 'media'), + onerror: function(e) { vnode.state.error = e }, + media: this.article && this.article.media, + }), + m('form.editarticle.content', { + onsubmit: this.save.bind(this, vnode), + }, [ + m('label', 'Parent'), + m('select', { + onchange: this.updateParent.bind(this), + }, parents.map(function(item) { return m('option', { value: item.id || -1, selected: item.id === vnode.state.article.parent_id }, item.name) })), + m('label', 'Name'), + m('input', { + type: 'text', + value: this.article.name, + oninput: this.updateValue.bind(this, 'name'), + }), + m('label', 'Description'), + ( + this.loadedFroala ? + m('div', { + oncreate: function(div) { + vnode.state.froala = new FroalaEditor(div.dom, EditArticle.getFroalaOptions(), function() { + vnode.state.froala.html.set(vnode.state.article.description) + }) + }, + }) + : null + ), + m('label', 'Path'), + m('input', { + type: 'text', + value: this.article.path, + oninput: this.updateValue.bind(this, 'path'), + }), + m('div.loading-spinner', { hidden: this.loadedFroala }), + m('input', { + type: 'submit', + value: 'Save', + }), + ]), + m('div.fileupload', [ + 'Add file', + m('input', { + accept: '*', + type: 'file', + onchange: this.uploadFile.bind(this, vnode), + }), + (vnode.state.loading ? m('div.loading-spinner') : null), + ]), + ]), + ]) + ) + }, +} + +module.exports = EditArticle diff --git a/app/admin/editpage.js b/app/admin/editpage.js new file mode 100644 index 0000000..d40e86e --- /dev/null +++ b/app/admin/editpage.js @@ -0,0 +1,213 @@ +const m = require('mithril') + +const Authentication = require('../authentication') +const FileUpload = require('../widgets/fileupload') +const Froala = require('./froala') +const { createPage, updatePage, getPage, Tree } = require('../api/page') + +const EditPage = { + getFroalaOptions: function() { + return { + theme: 'gray', + heightMin: 150, + videoUpload: false, + imageUploadURL: '/api/media', + imageManagerLoadURL: '/api/media', + imageManagerDeleteMethod: 'DELETE', + imageManagerDeleteURL: '/api/media', + events: { + 'imageManager.beforeDeleteImage': function(img) { + this.opts.imageManagerDeleteURL = '/api/media/' + img.data('id') + }, + }, + requestHeaders: { + 'Authorization': 'Bearer ' + Authentication.getToken(), + }, + } + }, + + oninit: function(vnode) { + this.loading = m.route.param('key') !== 'add' + this.creating = m.route.param('key') === 'add' + this.error = '' + this.page = { + name: '', + path: '', + description: '', + media: null, + } + this.editedPath = false + this.froala = null + this.loadedFroala = Froala.loadedFroala + + if (m.route.param('key') !== 'add') { + getPage(m.route.param('key')) + .then(function(result) { + vnode.state.editedPath = true + vnode.state.page = result + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + } + + if (!this.loadedFroala) { + Froala.createFroalaScript() + .then(function() { + vnode.state.loadedFroala = true + m.redraw() + }) + } + }, + + updateValue: function(name, e) { + this.page[name] = e.currentTarget.value + if (name === 'path') { + this.editedPath = true + } else if (name === 'name' && !this.editedPath) { + this.page.path = this.page.name.toLowerCase().replace(/ /g, '-') + } + }, + + updateParent: function(e) { + this.page.parent_id = Number(e.currentTarget.value) + if (this.page.parent_id === -1) { + this.page.parent_id = null + } + }, + + fileUploaded: function(type, media) { + this.page[type] = media + }, + + save: function(vnode, e) { + e.preventDefault() + if (!this.page.name) { + this.error = 'Name is missing' + } else if (!this.page.path) { + this.error = 'Path is missing' + } + if (this.error) return + + this.page.description = vnode.state.froala ? vnode.state.froala.html.get() : this.page.description + + this.loading = true + + let promise + + if (this.page.id) { + promise = updatePage(this.page.id, { + name: this.page.name, + path: this.page.path, + parent_id: this.page.parent_id, + description: this.page.description, + banner_id: this.page.banner && this.page.banner.id, + media_id: this.page.media && this.page.media.id, + }) + } else { + promise = createPage({ + name: this.page.name, + path: this.page.path, + parent_id: this.page.parent_id, + description: this.page.description, + banner_id: this.page.banner && this.page.banner.id, + media_id: this.page.media && this.page.media.id, + }) + } + + promise.then(function(res) { + if (vnode.state.page.id) { + res.media = vnode.state.page.media + res.banner = vnode.state.page.banner + vnode.state.page = res + } else { + m.route.set('/admin/pages/' + res.id) + } + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + view: function(vnode) { + const parents = [{id: null, name: '-- Frontpage --'}].concat(Tree).filter(function (page) { return !vnode.state.page || page.id !== vnode.state.page.id}) + return ( + this.loading ? + m('div.loading-spinner') + : m('div.admin-wrapper', [ + m('div.admin-actions', this.page.id + ? [ + m('span', 'Actions:'), + m(m.route.Link, { href: '/page/' + this.page.path }, 'View page'), + ] + : null), + m('article.editpage', [ + m('header', m('h1', this.creating ? 'Create Page' : 'Edit ' + (this.page.name || '(untitled)'))), + m('div.error', { + hidden: !this.error, + onclick: function() { vnode.state.error = '' } + }, this.error), + m(FileUpload, { + onupload: this.fileUploaded.bind(this, 'banner'), + onerror: function(e) { vnode.state.error = e }, + media: this.page && this.page.banner, + }), + m(FileUpload, { + class: 'cover', + useimg: true, + onupload: this.fileUploaded.bind(this, 'media'), + onerror: function(e) { vnode.state.error = e }, + media: this.page && this.page.media, + }), + m('form.editpage.content', { + onsubmit: this.save.bind(this, vnode), + }, [ + m('label', 'Parent'), + m('select', { + onchange: this.updateParent.bind(this), + }, parents.map(function(item) { return m('option', { value: item.id || -1, selected: item.id === vnode.state.page.parent_id }, item.name) })), + m('label', 'Name'), + m('input', { + type: 'text', + value: this.page.name, + oninput: this.updateValue.bind(this, 'name'), + }), + m('label', 'Description'), + ( + this.loadedFroala ? + m('div', { + oncreate: function(div) { + vnode.state.froala = new FroalaEditor(div.dom, EditPage.getFroalaOptions(), function() { + vnode.state.froala.html.set(vnode.state.page.description) + }) + }, + }) + : null + ), + m('label', 'Path'), + m('input', { + type: 'text', + value: this.page.path, + oninput: this.updateValue.bind(this, 'path'), + }), + m('div.loading-spinner', { hidden: this.loadedFroala }), + m('input', { + type: 'submit', + value: 'Save', + }), + ]), + ]), + ]) + ) + }, +} + +module.exports = EditPage diff --git a/app/admin/froala.js b/app/admin/froala.js new file mode 100644 index 0000000..fc1b214 --- /dev/null +++ b/app/admin/froala.js @@ -0,0 +1,46 @@ +const Froala = { + files: [ + { type: 'css', url: 'https://cdn.jsdelivr.net/npm/froala-editor@3.0.4/css/froala_editor.pkgd.min.css' }, + { type: 'css', url: 'https://cdn.jsdelivr.net/npm/froala-editor@3.0.4/css/themes/gray.min.css' }, + { type: 'js', url: 'https://cdn.jsdelivr.net/npm/froala-editor@3.0.4/js/froala_editor.pkgd.min.js' }, + ], + loadedFiles: 0, + loadedFroala: false, + + checkLoadedAll: function(res) { + if (Froala.loadedFiles < Froala.files.length) { + return + } + Froala.loadedFroala = true + res() + }, + + createFroalaScript: function() { + if (Froala.loadedFroala) return Promise.resolve() + return new Promise(function(res) { + let onload = function() { + Froala.loadedFiles++ + Froala.checkLoadedAll(res) + } + let head = document.getElementsByTagName('head')[0] + + for (var i = 0; i < Froala.files.length; i++) { + let element + if (Froala.files[i].type === 'css') { + element = document.createElement('link') + element.setAttribute('rel', 'stylesheet') + element.setAttribute('type', 'text/css') + element.setAttribute('href', Froala.files[i].url) + } else { + element = document.createElement('script') + element.setAttribute('type','text/javascript') + element.setAttribute('src', Froala.files[i].url) + } + element.onload = onload + head.insertBefore(element, head.firstChild) + } + }) + }, +} + +module.exports = Froala diff --git a/app/admin/pages.js b/app/admin/pages.js new file mode 100644 index 0000000..a42949f --- /dev/null +++ b/app/admin/pages.js @@ -0,0 +1,114 @@ +const m = require('mithril') + +const Authentication = require('../authentication') +const { getAllPages, removePage } = require('../api/page') +const Dialogue = require('../widgets/dialogue') + +const AdminPages = { + parseTree: function(pages) { + let map = new Map() + for (let i = 0; i < pages.length; i++) { + pages[i].children = [] + map.set(pages[i].id, pages[i]) + } + for (let i = 0; i < pages.length; i++) { + if (pages[i].parent_id && map.has(pages[i].parent_id)) { + map.get(pages[i].parent_id).children.push(pages[i]) + pages.splice(i, 1) + i-- + } + } + return pages + }, + + oninit: function(vnode) { + this.loading = true + this.error = '' + this.pages = [] + this.removePage = null + + getAllPages() + .then(function(result) { + vnode.state.pages = AdminPages.parseTree(result) + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + confirmRemovePage: function(vnode) { + let removingPage = this.removePage + this.removePage = null + this.loading = true + removePage(removingPage, removingPage.id) + .then(this.oninit.bind(this, vnode)) + .catch(function(err) { + vnode.state.error = err.message + vnode.state.loading = false + m.redraw() + }) + }, + + drawPage: function(vnode, page) { + return [ + m('tr', [ + m('td', [ + page.parent_id ? m('span.subpage', '| >') : null, + m(m.route.Link, { href: '/admin/pages/' + page.id }, page.name), + ]), + m('td', m(m.route.Link, { href: '/page/' + page.path }, '/page/' + page.path)), + m('td.right', page.updated_at.replace('T', ' ').split('.')[0]), + m('td.right', m('button', { onclick: function() { vnode.state.removePage = page } }, 'Remove')), + ]) + ].concat(page.children.map(AdminPages.drawPage.bind(this, vnode))) + }, + + view: function(vnode) { + return [ + (this.loading ? + m('div.loading-spinner') + : m('div.admin-wrapper', [ + m('div.admin-actions', [ + m('span', 'Actions:'), + m(m.route.Link, { href: '/admin/pages/add' }, 'Create new page'), + ]), + m('article.editpage', [ + m('header', m('h1', 'All pages')), + m('div.error', { + hidden: !this.error, + onclick: function() { vnode.state.error = '' } + }, this.error), + m('table', [ + m('thead', + m('tr', [ + m('th', 'Title'), + m('th', 'Path'), + m('th.right', 'Updated'), + m('th.right', 'Actions'), + ]) + ), + m('tbody', this.pages.map(AdminPages.drawPage.bind(this, vnode))), + ]), + ]), + ]) + ), + m(Dialogue, { + hidden: vnode.state.removePage === null, + title: 'Delete ' + (vnode.state.removePage ? vnode.state.removePage.name : ''), + message: 'Are you sure you want to remove "' + (vnode.state.removePage ? vnode.state.removePage.name : '') + '" (' + (vnode.state.removePage ? vnode.state.removePage.path : '') + ')', + yes: 'Remove', + yesclass: 'alert', + no: 'Cancel', + noclass: 'cancel', + onyes: this.confirmRemovePage.bind(this, vnode), + onno: function() { vnode.state.removePage = null }, + }), + ] + }, +} + +module.exports = AdminPages diff --git a/app/admin/pages.scss b/app/admin/pages.scss new file mode 100644 index 0000000..2fb58bb --- /dev/null +++ b/app/admin/pages.scss @@ -0,0 +1,66 @@ + +article.editpage { + text-align: center; + background: white; + padding: 0 0 20px; + + header { + padding: 10px; + background: $secondary-bg; + + h1 { + color: $secondary-fg; + } + + a { + font-size: 14px; + text-decoration: none; + margin-left: 10px; + color: $secondary-light-fg; + } + } + + fileupload { + margin: 0 0 20px; + + .inside { + height: 100px; + } + } + + fileupload.cover { + align-self: center; + min-width: 178px; + + .display { + background-size: auto 100%; + } + } + + form { + padding: 0 40px 20px; + + textarea { + height: 300px; + } + + .loading-spinner { + height: 300px; + position: relative; + } + } + + h5 { + margin-bottom: 20px; + } + + & > .loading-spinner { + width: 240px; + height: 50px; + position: relative; + } +} + +table span.subpage { + padding: 0 5px; +} diff --git a/app/api/article.js b/app/api/article.js new file mode 100644 index 0000000..fde8c23 --- /dev/null +++ b/app/api/article.js @@ -0,0 +1,38 @@ +const { sendRequest } = require('./common') + +exports.createArticle = function(body) { + return sendRequest({ + method: 'POST', + url: '/api/articles', + body: body, + }) +} + +exports.updateArticle = function(id, body) { + return sendRequest({ + method: 'PUT', + url: '/api/articles/' + id, + body: body, + }) +} + +exports.getAllArticles = function() { + return sendRequest({ + method: 'GET', + url: '/api/articles?includes=parent', + }) +} + +exports.getArticle = function(id) { + return sendRequest({ + method: 'GET', + url: '/api/articles/' + id + '?includes=media,parent,banner', + }) +} + +exports.removeArticle = function(article, id) { + return sendRequest({ + method: 'DELETE', + url: '/api/articles/' + id, + }) +} diff --git a/app/api/common.js b/app/api/common.js new file mode 100644 index 0000000..315f262 --- /dev/null +++ b/app/api/common.js @@ -0,0 +1,23 @@ +const m = require('mithril') +const Authentication = require('../authentication') + +exports.sendRequest = function(options) { + let token = Authentication.getToken() + + if (token) { + options.headers = options.headers || {} + options.headers['Authorization'] = 'Bearer ' + token + } + + return m.request(options) + .catch(function (error) { + if (error.code === 403) { + Authentication.clearToken() + m.route.set('/login', { redirect: m.route.get() }) + } + if (error.response && error.response.status) { + return Promise.reject(error.response) + } + return Promise.reject(error) + }) +} diff --git a/app/api/media.js b/app/api/media.js new file mode 100644 index 0000000..c790f12 --- /dev/null +++ b/app/api/media.js @@ -0,0 +1,13 @@ +const m = require('mithril') +const { sendRequest } = require('./common') + +exports.uploadMedia = function(file) { + let formData = new FormData() + formData.append('file', file) + + return sendRequest({ + method: 'POST', + url: '/api/media', + body: formData, + }) +} diff --git a/app/api/page.js b/app/api/page.js new file mode 100644 index 0000000..097857a --- /dev/null +++ b/app/api/page.js @@ -0,0 +1,99 @@ +const { sendRequest } = require('./common') + +const Tree = [] + +exports.Tree = Tree + +exports.createPage = function(body) { + return sendRequest({ + method: 'POST', + url: '/api/pages', + body: body, + }).then(function(res) { + res.children = [] + if (!res.parent_id) { + Tree.push(res) + } else { + for (let i = 0; i < Tree.length; i++) { + if (Tree[i].id === res.parent_id) { + Tree[i].children.push(res) + break + } + } + } + return res + }) +} + +exports.getTree = function(body) { + return sendRequest({ + method: 'GET', + url: '/api/pages?tree=true&includes=children&fields=id,name,path,children(id,name,path)', + }) +} + +exports.updatePage = function(id, body) { + return sendRequest({ + method: 'PUT', + url: '/api/pages/' + id, + body: body, + }).then(function(res) { + for (let i = 0; i < Tree.length; i++) { + if (Tree[i].id === res.id) { + res.children = Tree[i].children + Tree[i] = res + break + } else if (Tree[i].id === res.parent_id) { + for (let x = 0; x < Tree[i].children.length; x++) { + if (Tree[i].children[x].id === res.id) { + res.children = Tree[i].children[x].children + Tree[i].children[x] = res + break + } + } + break + } + } + if (!res.children) { + res.children = [] + } + return res + }) +} + +exports.getAllPages = function() { + return sendRequest({ + method: 'GET', + url: '/api/pages', + }) +} + +exports.getPage = function(id) { + return sendRequest({ + method: 'GET', + url: '/api/pages/' + id + '?includes=media,banner,children,news,news.media', + }) +} + +exports.removePage = function(page, id) { + return sendRequest({ + method: 'DELETE', + url: '/api/pages/' + id, + }).then(function() { + for (let i = 0; i < Tree.length; i++) { + if (Tree[i].id === page.id) { + Tree.splice(i, 1) + break + } else if (Tree[i].id === page.parent_id) { + for (let x = 0; x < Tree[i].children.length; x++) { + if (Tree[i].children[x].id === page.id) { + Tree[i].children.splice(x, 1) + break + } + } + break + } + } + return null + }) +} diff --git a/app/app.sass b/app/app.sass deleted file mode 100644 index 98cebb5..0000000 --- a/app/app.sass +++ /dev/null @@ -1,4 +0,0 @@ - -body - margin: 0 - padding: 0 diff --git a/app/app.scss b/app/app.scss new file mode 100644 index 0000000..6e0ac0e --- /dev/null +++ b/app/app.scss @@ -0,0 +1,239 @@ +@import './_common'; + +html { + box-sizing: border-box; + font-size: 16px; + height: 100%; +} + +*, *:before, *:after { + box-sizing: inherit; +} + +body, h1, h2, h3, h4, h5, h6, p { + margin: 0; + padding: 0; + font-weight: normal; +} + +body { + height: 100%; + font-family: Arial, Helvetica, sans-serif; +} + +ol, ul { + list-style: none; +} + +img { + max-width: 100%; + height: auto; +} + +@keyframes spinner-loader { + to {transform: rotate(360deg);} +} + +.loading-spinner:before { + content: ''; + box-sizing: border-box; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin-top: -10px; + margin-left: -10px; + border-radius: 50%; + border: 2px solid #ccc; + border-top-color: #333; + animation: spinner-loader .6s linear infinite; +} + +.container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +main { + display: flex; + flex-direction: column; + flex-grow: 2; +} + +.error { + font-size: 0.8em; + color: $secondary-dark-bg; + font-weight: bold; + padding-bottom: 20px; +} + +[hidden] { display: none !important; } + +article { + display: flex; + flex-direction: column; + flex-grow: 2; + padding: 20px; + + header { + display: flex; + flex-direction: column; + margin-bottom: 20px; + + h1 { + font-size: 1.4em; + color: $title-fg; + flex-grow: 2; + } + + span { + font-size: 0.8em; + color: $meta-fg; + } + } + + .content { + display: flex; + flex-direction: column; + + h5 { + font-size: 1.0em; + font-weight: bold; + color: $title-fg; + } + } +} + +form { + display: flex; + flex-direction: column; + align-items: stretch; + + label { + text-align: left; + font-weight: bold; + font-size: 0.8em; + padding: 5px 0 3px; + } + + input[type=text], + input[type=password], + select, + textarea { + width: 100%; + font-size: 1em; + padding: 0.5em; + margin: 0 0 0.5em; + background: white; + border: 1px solid #333; + color: black; + outline: none; + + &:hover, + &:focus { + border-color: $secondary-bg; + } + } + + input[type=submit] { + align-self: center; + padding: 0.5em; + margin: 0.5em 0; + min-width: 150px; + border: none; + border: 1px solid $secondary-bg; + background: $secondary-light-bg; + color: $secondary-light-fg; + + &:hover { + background: $secondary-dark-bg; + color: $secondary-dark-fg; + } + } +} + +.fr-view { + .clearfix::after{clear:both;display:block;content:"";height:0}.hide-by-clipping{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}img.fr-rounded,.fr-img-caption.fr-rounded img{border-radius:10px;-moz-border-radius:10px;-webkit-border-radius:10px;-moz-background-clip:padding;-webkit-background-clip:padding-box;background-clip:padding-box}img.fr-bordered,.fr-img-caption.fr-bordered img{border:solid 5px #CCC}img.fr-bordered{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}.fr-img-caption.fr-bordered img{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.fr-view{word-wrap:break-word}.fr-view span[style~="color:"] a{color:inherit}.fr-view strong{font-weight:700}.fr-view table{border:none;border-collapse:collapse;empty-cells:show;max-width:100%}.fr-view table td{min-width:5px}.fr-view table.fr-dashed-borders td,.fr-view table.fr-dashed-borders th{border-style:dashed}.fr-view table.fr-alternate-rows tbody tr:nth-child(2n){background:whitesmoke}.fr-view table td,.fr-view table th{border:1px solid #DDD}.fr-view table td:empty,.fr-view table th:empty{height:20px}.fr-view table td.fr-highlighted,.fr-view table th.fr-highlighted{border:1px double red}.fr-view table td.fr-thick,.fr-view table th.fr-thick{border-width:2px}.fr-view table th{background:#ececec}.fr-view hr{clear:both;user-select:none;-o-user-select:none;-moz-user-select:none;-khtml-user-select:none;-webkit-user-select:none;-ms-user-select:none;page-break-after:always}.fr-view .fr-file{position:relative}.fr-view .fr-file::after{position:relative;content:"\1F4CE";font-weight:normal}.fr-view pre{white-space:pre-wrap;word-wrap:break-word;overflow:visible}.fr-view[dir="rtl"] blockquote{border-left:none;border-right:solid 2px #5E35B1;margin-right:0;padding-right:5px;padding-left:0}.fr-view[dir="rtl"] blockquote blockquote{border-color:#00BCD4}.fr-view[dir="rtl"] blockquote blockquote blockquote{border-color:#43A047}.fr-view blockquote{border-left:solid 2px #5E35B1;margin-left:0;padding-left:5px;color:#5E35B1}.fr-view blockquote blockquote{border-color:#00BCD4;color:#00BCD4}.fr-view blockquote blockquote blockquote{border-color:#43A047;color:#43A047}.fr-view span.fr-emoticon{font-weight:normal;font-family:"Apple Color Emoji","Segoe UI Emoji","NotoColorEmoji","Segoe UI Symbol","Android Emoji","EmojiSymbols";display:inline;line-height:0}.fr-view span.fr-emoticon.fr-emoticon-img{background-repeat:no-repeat !important;font-size:inherit;height:1em;width:1em;min-height:20px;min-width:20px;display:inline-block;margin:-.1em .1em .1em;line-height:1;vertical-align:middle}.fr-view .fr-text-gray{color:#AAA !important}.fr-view .fr-text-bordered{border-top:solid 1px #222;border-bottom:solid 1px #222;padding:10px 0}.fr-view .fr-text-spaced{letter-spacing:1px}.fr-view .fr-text-uppercase{text-transform:uppercase}.fr-view .fr-class-highlighted{background-color:#ffff00}.fr-view .fr-class-code{border-color:#cccccc;border-radius:2px;-moz-border-radius:2px;-webkit-border-radius:2px;-moz-background-clip:padding;-webkit-background-clip:padding-box;background-clip:padding-box;background:#f5f5f5;padding:10px;font-family:"Courier New", Courier, monospace}.fr-view .fr-class-transparency{opacity:0.5}.fr-view img{position:relative;max-width:100%}.fr-view img.fr-dib{margin:5px auto;display:block;float:none;vertical-align:top}.fr-view img.fr-dib.fr-fil{margin-left:0;text-align:left}.fr-view img.fr-dib.fr-fir{margin-right:0;text-align:right}.fr-view img.fr-dii{display:inline-block;float:none;vertical-align:bottom;margin-left:5px;margin-right:5px;max-width:calc(100% - (2 * 5px))}.fr-view img.fr-dii.fr-fil{float:left;margin:5px 5px 5px 0;max-width:calc(100% - 5px)}.fr-view img.fr-dii.fr-fir{float:right;margin:5px 0 5px 5px;max-width:calc(100% - 5px)}.fr-view span.fr-img-caption{position:relative;max-width:100%}.fr-view span.fr-img-caption.fr-dib{margin:5px auto;display:block;float:none;vertical-align:top}.fr-view span.fr-img-caption.fr-dib.fr-fil{margin-left:0;text-align:left}.fr-view span.fr-img-caption.fr-dib.fr-fir{margin-right:0;text-align:right}.fr-view span.fr-img-caption.fr-dii{display:inline-block;float:none;vertical-align:bottom;margin-left:5px;margin-right:5px;max-width:calc(100% - (2 * 5px))}.fr-view span.fr-img-caption.fr-dii.fr-fil{float:left;margin:5px 5px 5px 0;max-width:calc(100% - 5px)}.fr-view span.fr-img-caption.fr-dii.fr-fir{float:right;margin:5px 0 5px 5px;max-width:calc(100% - 5px)}.fr-view .fr-video{text-align:center;position:relative}.fr-view .fr-video.fr-rv{padding-bottom:56.25%;padding-top:30px;height:0;overflow:hidden}.fr-view .fr-video.fr-rv>iframe,.fr-view .fr-video.fr-rv object,.fr-view .fr-video.fr-rv embed{position:absolute !important;top:0;left:0;width:100%;height:100%}.fr-view .fr-video>*{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;max-width:100%;border:none}.fr-view .fr-video.fr-dvb{display:block;clear:both}.fr-view .fr-video.fr-dvb.fr-fvl{text-align:left}.fr-view .fr-video.fr-dvb.fr-fvr{text-align:right}.fr-view .fr-video.fr-dvi{display:inline-block}.fr-view .fr-video.fr-dvi.fr-fvl{float:left}.fr-view .fr-video.fr-dvi.fr-fvr{float:right}.fr-view a.fr-strong{font-weight:700}.fr-view a.fr-green{color:green}.fr-view .fr-img-caption{text-align:center}.fr-view .fr-img-caption .fr-img-wrap{padding:0;display:inline-block;margin:auto;text-align:center;width:100%}.fr-view .fr-img-caption .fr-img-wrap img{display:block;margin:auto;width:100%}.fr-view .fr-img-caption .fr-img-wrap>span{margin:auto;display:block;padding:5px 5px 10px;font-size:14px;font-weight:initial;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;-webkit-opacity:0.9;-moz-opacity:0.9;opacity:0.9;-ms-filter:"progid:DXImageTransform.Microsoft.Alpha(Opacity=0)";width:100%;text-align:center}.fr-view button.fr-rounded,.fr-view input.fr-rounded,.fr-view textarea.fr-rounded{border-radius:10px;-moz-border-radius:10px;-webkit-border-radius:10px;-moz-background-clip:padding;-webkit-background-clip:padding-box;background-clip:padding-box}.fr-view button.fr-large,.fr-view input.fr-large,.fr-view textarea.fr-large{font-size:24px}a.fr-view.fr-strong{font-weight:700}a.fr-view.fr-green{color:green}img.fr-view{position:relative;max-width:100%}img.fr-view.fr-dib{margin:5px auto;display:block;float:none;vertical-align:top}img.fr-view.fr-dib.fr-fil{margin-left:0;text-align:left}img.fr-view.fr-dib.fr-fir{margin-right:0;text-align:right}img.fr-view.fr-dii{display:inline-block;float:none;vertical-align:bottom;margin-left:5px;margin-right:5px;max-width:calc(100% - (2 * 5px))}img.fr-view.fr-dii.fr-fil{float:left;margin:5px 5px 5px 0;max-width:calc(100% - 5px)}img.fr-view.fr-dii.fr-fir{float:right;margin:5px 0 5px 5px;max-width:calc(100% - 5px)}span.fr-img-caption.fr-view{position:relative;max-width:100%}span.fr-img-caption.fr-view.fr-dib{margin:5px auto;display:block;float:none;vertical-align:top}span.fr-img-caption.fr-view.fr-dib.fr-fil{margin-left:0;text-align:left}span.fr-img-caption.fr-view.fr-dib.fr-fir{margin-right:0;text-align:right}span.fr-img-caption.fr-view.fr-dii{display:inline-block;float:none;vertical-align:bottom;margin-left:5px;margin-right:5px;max-width:calc(100% - (2 * 5px))}span.fr-img-caption.fr-view.fr-dii.fr-fil{float:left;margin:5px 5px 5px 0;max-width:calc(100% - 5px)}span.fr-img-caption.fr-view.fr-dii.fr-fir{float:right;margin:5px 0 5px 5px;max-width:calc(100% - 5px)} + h1, h2, h3, h4, h5, h6, p, dl, ol, ul { + margin: 0 0 1em !important; + } + + a { color: $secondary-dark-bg; } + dt { font-weight: bold; } + ol { list-style-type: decimal; padding-left: 40px; } + ul { list-style-type: disc; padding-left: 40px; } + h1 { font-size: 1.8em; font-weight: bold; } + h2 { font-size: 1.6em; font-weight: bold; } + h3 { font-size: 1.4em; font-weight: bold; } + h4 { font-size: 1.2em; font-weight: bold; } + h5 { font-size: 1.0em; font-weight: bold; } + h6 { font-size: 0.8em; font-weight: bold; } + hr { width: 100%; } + +} + +$bordercolor: $primary-bg; +$headcolor: $primary-light-bg; +$headtext: $primary-light-fg; + +table { + width: calc(100% - 20px); + margin: 10px; + border: solid 1px $bordercolor; + border-collapse: collapse; + border-spacing: 0; + font-size: 0.8em; +} +table thead th { + background-color: $headcolor; + border: solid 1px $bordercolor; + color: $headtext; + padding: 10px; + text-align: left; +} +table tbody td { + text-align: left; + border: solid 1px $bordercolor; + color: #333; + padding: 10px; +} +table a, +table a:visited, +table a:hover { + text-decoration: none; + color: $secondary-bg; + font-weight: bold; +} + +table button { + color: $secondary-bg; + background: transparent; + border: 1px solid $secondary-bg; +} + +table td.right, +table th.right { + text-align: right; +} + +.floating-container { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: #00000099; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +@import 'menu/menu'; +@import 'login/login'; +@import 'admin/admin'; +@import 'widgets/common'; +@import 'pages/page'; diff --git a/app/authentication.js b/app/authentication.js new file mode 100644 index 0000000..12fa25c --- /dev/null +++ b/app/authentication.js @@ -0,0 +1,55 @@ +const m = require('mithril') + +const storageName = 'logintoken' +const loadingListeners = [] + +window.googleLoaded = function() { + Authentication.loadedGoogle = true + while (Authentication.loadingListeners.length) { + Authentication.loadingListeners.pop()() + } +} + +const Authentication = { + currentUser: null, + loadedGoogle: false, + loadingGoogle: false, + loadingListeners: [], + + updateToken: function(token) { + if (!token) return Authentication.clearToken() + localStorage.setItem(storageName, token) + Authentication.currentUser = JSON.parse(atob(token.split('.')[1])) + }, + + clearToken: function() { + Authentication.currentUser = null + localStorage.removeItem(storageName) + }, + + createGoogleScript: function() { + if (Authentication.loadedGoogle) return Promise.resolve() + return new Promise(function (res) { + if (Authentication.loadedGoogle) return res() + Authentication.loadingListeners.push(res) + + if (Authentication.loadingGoogle) return + Authentication.loadingGoogle = true + + let gscript = document.createElement('script') + gscript.type = 'text/javascript' + gscript.async = true + gscript.defer = true + gscript.src = 'https://apis.google.com/js/platform.js?onload=googleLoaded' + document.body.appendChild(gscript) + }) + }, + + getToken: function() { + return localStorage.getItem(storageName) + }, +} + +Authentication.updateToken(localStorage.getItem(storageName)) + +module.exports = Authentication diff --git a/app/frontpage/frontpage.js b/app/frontpage/frontpage.js new file mode 100644 index 0000000..6a3f49f --- /dev/null +++ b/app/frontpage/frontpage.js @@ -0,0 +1,12 @@ +const m = require('mithril') + +module.exports = { + view: function() { + return m('article', [ + m('header', [ + m('h1', 'Welcome to NFP Moe'), + m('span.meta', 'Last updated many years ago'), + ]), + ]) + } +} diff --git a/app/index.js b/app/index.js index a92ff52..46f0c32 100644 --- a/app/index.js +++ b/app/index.js @@ -1 +1,29 @@ -console.log('Success') \ No newline at end of file +const m = require('mithril') + +m.route.prefix = '' + +const Authentication = require('./authentication') +const Menu = require('./menu/menu') +const Frontpage = require('./frontpage/frontpage') +const Login = require('./login/login') +const Logout = require('./login/logout') +const EditPage = require('./admin/editpage') +const Page = require('./pages/page') +const AdminPages = require('./admin/pages') +const AdminArticles = require('./admin/articles') +const EditArticle = require('./admin/editarticle') + +const menuRoot = document.getElementById('nav') +const mainRoot = document.getElementById('main') + +m.route(mainRoot, '/', { + '/': Frontpage, + '/login': Login, + '/logout': Logout, + '/page/:key': Page, + '/admin/pages': AdminPages, + '/admin/pages/:key': EditPage, + '/admin/articles': AdminArticles, + '/admin/articles/:key': EditArticle, +}) +m.mount(menuRoot, Menu) diff --git a/app/login/login.js b/app/login/login.js new file mode 100644 index 0000000..85d4a2f --- /dev/null +++ b/app/login/login.js @@ -0,0 +1,87 @@ +const m = require('mithril') +const Authentication = require('../authentication') + +const Login = { + loadedGoogle: false, + loading: false, + redirect: '', + error: '', + + initGoogleButton: function() { + gapi.signin2.render('googlesignin', { + 'scope': 'email', + 'width': 240, + 'height': 50, + 'longtitle': true, + 'theme': 'dark', + 'onsuccess': Login.onGoogleSuccess, + 'onfailure': Login.onGoogleFailure, + }) + }, + + onGoogleSuccess: function(googleUser) { + Login.loading = true + m.redraw() + + m.request({ + method: 'POST', + url: '/api/login', + body: { token: googleUser.Zi.access_token }, + }) + .then(function(result) { + Authentication.updateToken(result.token) + m.route.set(Login.redirect || '/') + }) + .catch(function(error) { + Login.error = 'Error while logging into NFP! ' + error.status + ': ' + error.message + let auth2 = gapi.auth2.getAuthInstance() + return auth2.signOut() + }) + .then(function () { + Login.loading = false + m.redraw() + }) + }, + + onGoogleFailure: function(error) { + if (error.error !== 'popup_closed_by_user' && error.error !== 'popup_blocked_by_browser') { + console.error(error) + Login.error = 'Error while logging into Google: ' + error.error + m.redraw() + } + }, + + oninit: function(vnode) { + Login.redirect = vnode.attrs.redirect || '' + if (Authentication.currentUser) return m.route.set('/') + Login.error = '' + }, + + oncreate: function() { + if (Authentication.currentUser) return + Authentication.createGoogleScript() + .then(function() { + Login.initGoogleButton() + }) + }, + + view: function() { + return [ + m('div.login-wrapper', [ + m('article.login', [ + m('header', [ + m('h1', 'NFP.moe login'), + ]), + m('div.content', [ + m('h5', 'Please login using google to access restricted area'), + Login.error ? m('div.error', Login.error) : null, + Login.loading ? m('div.loading-spinner') : null, + m('div#googlesignin', { hidden: Login.loading }, m('div.loading-spinner')), + ]), + ]), + ]), + ] + }, +} + +module.exports = Login diff --git a/app/login/login.scss b/app/login/login.scss new file mode 100644 index 0000000..58fa542 --- /dev/null +++ b/app/login/login.scss @@ -0,0 +1,33 @@ +@import '../_common'; + +.login-wrapper { + flex-grow: 2; + display: flex; + flex-direction: column; + justify-content: center; + background: $border; +} + +article.login { + text-align: center; + flex-grow: 0; + border: 1px solid $title-fg; + background: white; + align-self: center; + + .content { + align-items: center; + align-self: center; + padding: 20px 40px; + } + + h5 { + margin-bottom: 20px; + } + + .loading-spinner { + width: 240px; + height: 50px; + position: relative; + } +} diff --git a/app/login/logout.js b/app/login/logout.js new file mode 100644 index 0000000..f825992 --- /dev/null +++ b/app/login/logout.js @@ -0,0 +1,30 @@ +const m = require('mithril') +const Authentication = require('../authentication') + +const Logout = { + oninit: function() { + Authentication.createGoogleScript() + .then(function() { + return new Promise(function (res) { + gapi.load('auth2', res); + }) + }) + .then(function() { return gapi.auth2.init() }) + .then(function() { + let auth2 = gapi.auth2.getAuthInstance(); + return auth2.signOut() + }) + .then(function() { + Authentication.clearToken() + m.route.set('/') + }, function(err) { + console.log('unable to logout:', err) + }) + }, + + view: function() { + return m('div.loading-spinner') + }, +} + +module.exports = Logout diff --git a/app/menu/menu.js b/app/menu/menu.js new file mode 100644 index 0000000..2f7e4cc --- /dev/null +++ b/app/menu/menu.js @@ -0,0 +1,79 @@ +const m = require('mithril') +const Authentication = require('../authentication') +const { getAllPages, Tree, getTree } = require('../api/page') + +const Menu = { + currentActive: 'home', + error: '', + loading: false, + + onbeforeupdate: function() { + let currentPath = m.route.get() + if (currentPath === '/') Menu.currentActive = 'home' + else if (currentPath === '/login') Menu.currentActive = 'login' + else Menu.currentActive = currentPath + }, + + oninit: function(vnode) { + Menu.onbeforeupdate() + + Menu.loading = true + + getTree() + .then(function(results) { + Tree.splice(0, Tree.Length) + Tree.push.apply(Tree, results) + }) + .catch(function(err) { + Menu.error = err.message + }) + .then(function() { + Menu.loading = false + m.redraw() + }) + }, + + view: function() { + return [ + m('div.top', [ + m('h2', 'NFP Moe'), + m('aside', Authentication.currentUser ? [ + m('p', 'Welcome ' + Authentication.currentUser.email), + (Authentication.currentUser.level >= 100 + ? [ + m(m.route.Link, { href: '/admin/pages' }, 'Pages'), + m(m.route.Link, { href: '/admin/articles' }, 'Articles'), + ] + : null + ), + m(m.route.Link, { href: '/logout' }, 'Logout') + ] : [ + m(m.route.Link, { href: '/login' }, 'Login') + ]) + ]), + m('nav', [ + m(m.route.Link, { + href: '/', + class: Menu.currentActive === 'home' ? 'active' : '', + }, 'Home'), + Menu.loading ? m('div.loading-spinner') : Tree.map(function(page) { + if (page.children.length) { + return m('div.hassubmenu', [ + m(m.route.Link, { + href: '/page/' + page.path, + class: Menu.currentActive === ('/page/' + page.path) ? 'active' : '', + }, page.name) + ]) + } + return m(m.route.Link, { + href: '/page/' + page.path, + class: Menu.currentActive === ('/page/' + page.path) ? 'active' : '', + }, page.name) + }), + ]), + Menu.error ? m('div.menuerror', Menu.error) : null, + ] + } +} + +module.exports = Menu diff --git a/app/menu/menu.scss b/app/menu/menu.scss new file mode 100644 index 0000000..ab0f438 --- /dev/null +++ b/app/menu/menu.scss @@ -0,0 +1,85 @@ +@import '../_common'; + +#nav { + display: flex; + flex-direction: column; + + .top { + background: url('./img/logo.png') 25px center no-repeat $primary-dark-bg; + color: $primary-dark-fg; + padding: 0 10px 0 120px; + height: 100px; + display: flex; + + h2 { + flex-grow: 2; + align-self: center; + font-size: 30px; + } + + aside { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 0; + + p { + font-size: 0.8em; + color: $meta-light-fg; + padding-bottom: 5px; + } + + a, a:visited { + font-weight: bold; + text-align: center; + color: $secondary-light-bg; + font-size: 0.8em; + line-height: 1.4em; + text-decoration: none; + } + } + } + + nav { + display: flex; + background: $primary-light-bg; + color: $primary-light-fg; + + .hassubmenu { + flex-grow: 2; + flex-basis: 0; + display: flex; + } + + a, a:visited { + flex-grow: 2; + flex-basis: 0; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + color: $primary-light-fg; + padding: 10px; + font-size: 0.9em; + text-decoration: none; + + &.active { + border-bottom: 3px solid $secondary-bg; + } + } + } + + .loading-spinner { + position: relative; + flex-grow: 2; + flex-basis: 0; + } + + .menuerror { + background: $primary-bg; + text-align: center; + padding: 10px; + color: #FFC7C7; + font-weight: bold; + } +} diff --git a/app/pages/page.js b/app/pages/page.js new file mode 100644 index 0000000..df5ee87 --- /dev/null +++ b/app/pages/page.js @@ -0,0 +1,87 @@ +const m = require('mithril') +const { getPage } = require('../api/page') +const Authentication = require('../authentication') +const Newsentry = require('../widgets/newsentry') + +const Page = { + oninit: function(vnode) { + this.path = m.route.param('key') + this.error = '' + this.page = { + id: 0, + name: '', + path: '', + description: '', + media: null, + } + + this.loading = true + + getPage(this.path) + .then(function(result) { + vnode.state.page = result + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + view: function(vnode) { + return ( + this.loading ? + m('div.loading-spinner') + : m('article.page', [ + this.page.banner ? m('.div.page-banner', { style: { 'background-image': 'url(' + this.page.banner.url + ')' } } ) : null, + m('header', m('h1', this.page.name)), + m('.container', { + class: this.page.children.length ? 'multi' : '', + }, [ + this.page.children.length + ? m('aside.sidebar', [ + m('h4', 'View ' + this.page.name + ':'), + this.page.children.map(function(page) { + return m(m.route.Link, { href: '/page/' + page.path, }, page.name) + }), + ]) + : null, + this.page.description + ? m('.fr-view', [ + this.page.media ? m('img.page-cover', { src: this.page.media.url } ) : null, + m.trust(this.page.description), + this.page.news.length && this.page.description + ? m('aside.news', [ + m('h4', 'Latest updates under ' + this.page.name + ':'), + this.page.news.map(function(article) { + return m(Newsentry, article) + }), + ]) + : null + ]) + : null, + ]), + this.page.news.length && !this.page.description + ? m('aside.news', { + class: this.page.description ? '' : 'single' + }, [ + m('h4', 'Latest updates under ' + this.page.name + ':'), + this.page.news.map(function(article) { + return m(Newsentry, article) + }), + ]) + : null, + Authentication.currentUser + ? m('div.admin-actions', [ + m('span', 'Admin controls:'), + m(m.route.Link, { href: '/admin/pages/' + this.page.id }, 'Edit page'), + ]) + : null, + ]) + ) + }, +} + +module.exports = Page diff --git a/app/pages/page.scss b/app/pages/page.scss new file mode 100644 index 0000000..9fd3eeb --- /dev/null +++ b/app/pages/page.scss @@ -0,0 +1,161 @@ +article.page { + background: white; + padding: 0 0 20px; + + header { + text-align: center; + margin: 20px; + padding: 10px; + background: $secondary-bg; + width: 100%; + max-width: 1920px; + align-self: center; + + h1 { + color: $secondary-fg; + } + } + + .page-banner { + background-size: auto 100%; + background-repeat: no-repeat; + background-position: center; + height: 100px; + width: 100%; + max-width: 1920px; + align-self: center; + flex: 0 0 100px; + } + + .page-cover { + margin: 0 -10px 20px; + } + + .admin-actions { + margin-bottom: 20px; + } + + & > .loading-spinner { + width: 240px; + height: 50px; + position: relative; + } + + aside.sidebar, + aside.news { + h4 { + font-size: 14px; + font-weight: bold; + margin: 0 0 10px; + } + + a { + display: inline-block; + padding-top: 5px; + text-decoration: none; + color: $secondary-bg; + font-size: 14px; + font-weight: bold; + } + } + + .container { + flex-direction: column; + align-items: center; + height: auto; + + &.multi { + align-self: center; + align-items: flex-start; + flex-direction: row; + flex-grow: 2; + width: 100%; + max-width: 1050px; + } + } + + aside.sidebar { + width: 250px; + flex: 0 0 250px; + padding: 0 10px; + margin-bottom: 10px; + + h4 { + padding: 0 5px 5px; + border-bottom: 1px solid $border; + } + + a { + padding: 5px 5px 0px; + display: block; + } + } + + .fr-view { + margin: 0 20px; + padding: 0 20px; + width: calc(100% - 40px); + max-width: 800px; + flex: 2 0 0; + + main { + padding: 0 5px; + } + } +} + +aside.news { + border-top: 1px solid #ccc; + margin-top: 20px; + padding: 10px 10px; + margin: 0 -10px; + width: 100%; + align-self: center; + + newsentry { + margin: 0 0 10px; + } + + &.single { + max-width: 800px; + flex: 2 0 0; + border-top: none; + margin-top: 0; + + & > h4 { + display: none; + } + } +} + +@media screen and (max-device-width: 639px){ + article.page .container { + flex-direction: column !important; + } + + article.page aside.sidebar { + width: calc(100% - 80px); + flex: 0 0 auto; + margin: 0px 30px 30px; + border-bottom: 1px solid $border; + padding: 0 0 5px; + } +} + +@media screen and (max-device-width: 360px){ + article.page { + .container { + flex-direction: column; + } + + aside { + margin: 0px 0px 10px; + } + + .fr-view { + margin: 0; + width: 100%; + padding: 0 5px; + } + } +} diff --git a/app/widgets/common.scss b/app/widgets/common.scss new file mode 100644 index 0000000..b64eef0 --- /dev/null +++ b/app/widgets/common.scss @@ -0,0 +1,154 @@ + + +fileupload { + position: relative; + display: flex; + align-items: stretch; + flex-direction: column; + justify-content: stretch; + + .showicon, + .showbordericon, + .display { + flex-grow: 2; + } + + .showbordericon { + border: 3px solid $title-fg; + border-style: dashed; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHaSURBVHhe7dfPScRAAIXxFSxAPHjUkrx61DaswCJsw5MdCDYkgs6DDIQlu5l/Sd5Ovh88WDJ7yPCdcgAAAAAAAEDvHofBwFPYzzD9xoaew37D/obpt55hAy9h4xjjKDrDik7FiCPKiuZixBFlBakx4oiyoNwYcURZQGmMOKI0VBsjjigNtIoRR5QKrWPEEaVASoyPiWdx5840omRIifEe9nD0bDyd6T9TZ3FESZAa4yrsfvTseDrTf4hSISeGzAURohTKjSEpQYQomUpiSGoQIUqi0hiSE0SIMqMmhuQGEaKcUBtDSoIIUY60iCGlQYQog1YxpCaI7D5KyxhSG0R2G+U67Cts6sJxOTGkRRBJiaJ31x26chv2HTZ14dwY0iqInIuid9a7d2kqSkkMaRlEpqJ0HSMaRymNIa2DyDjKLmJEuuhrWGkMWSKI6J30bruJ0cpSQVCIIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZggCAACAjd2FfXY+3fFinPtG6GUX9a1DEDMEMUMQMwQxcxP21vl0RwAAAAAAAGCnDod/1p4xx4l+w0cAAAAASUVORK5CYII='); + background-position: center; + background-repeat: no-repeat; + background-size: 32px; + } + + .showicon { + position: absolute; + top: 50%; + left: 50%; + margin-left: -16px; + margin-top: -16px; + width: 32px; + height: 32px; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHaSURBVHhe7dfPScRAAIXxFSxAPHjUkrx61DaswCJsw5MdCDYkgs6DDIQlu5l/Sd5Ovh88WDJ7yPCdcgAAAAAAAEDvHofBwFPYzzD9xoaew37D/obpt55hAy9h4xjjKDrDik7FiCPKiuZixBFlBakx4oiyoNwYcURZQGmMOKI0VBsjjigNtIoRR5QKrWPEEaVASoyPiWdx5840omRIifEe9nD0bDyd6T9TZ3FESZAa4yrsfvTseDrTf4hSISeGzAURohTKjSEpQYQomUpiSGoQIUqi0hiSE0SIMqMmhuQGEaKcUBtDSoIIUY60iCGlQYQog1YxpCaI7D5KyxhSG0R2G+U67Cts6sJxOTGkRRBJiaJ31x26chv2HTZ14dwY0iqInIuid9a7d2kqSkkMaRlEpqJ0HSMaRymNIa2DyDjKLmJEuuhrWGkMWSKI6J30bruJ0cpSQVCIIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZggCAACAjd2FfXY+3fFinPtG6GUX9a1DEDMEMUMQMwQxcxP21vl0RwAAAAAAAGCnDod/1p4xx4l+w0cAAAAASUVORK5CYII='); + background-position: center; + background-repeat: no-repeat; + background-size: 32px; + } + + img { + max-width: 600px; + width: calc(100% - 80px); + align-self: center; + } + + .display { + background-size: cover; + background-repeat: no-repeat; + background-position: center; + } + + .loading-spinner { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #33333388; + width: 100%; + } + + input { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + opacity: 0.01; + width: 100%; + cursor: pointer; + text-indent: -9999px; + z-index: 2; + } +} + +dialogue { + background: white; + display: flex; + flex-direction: column; + text-align: center; + width: calc(100% - 40px); + max-width: 500px; +} + +dialogue h2 { + background: $secondary-dark-bg; + color: $secondary-dark-fg; + font-size: 1.5em; + padding: 10px; +} + +dialogue p { + padding: 10px; +} + +dialogue .buttons { + display: flex; + justify-content: space-around; + padding: 10px; +} + +dialogue button { + border: 1px solid $secondary-dark-bg; + background: transparent; + color: $secondary-dark-bg; + padding: 5px 15px; + min-width: 150px; +} + +dialogue button.alert { + border-color: red; + color: red; +} + +dialogue button.cancel { + border-color: #999; + color: #999; +} + +newsentry { + display: flex; + color: $meta-fg; + font-size: 12px; + + a { + &.cover { + flex-shrink: 0; + margin-right: 10px; + + img { + max-height: 70px; + width: auto; + } + } + + &.nobg { + height: 70px; + width: 124px; + background: #ddd; + } + } + + .entrycontent { + display: flex; + flex-direction: column; + flex: 2 1 auto; + padding: 0 5px 5px; + + h3 { + margin-bottom: 10px !important; + font-size: 1.3em; + font-weight: bold; + } + } +} diff --git a/app/widgets/dialogue.js b/app/widgets/dialogue.js new file mode 100644 index 0000000..fcbba92 --- /dev/null +++ b/app/widgets/dialogue.js @@ -0,0 +1,19 @@ +const m = require('mithril') + +const Dialogue = { + view: function(vnode) { + return m('div.floating-container', { + hidden: vnode.attrs.hidden, + }, m('dialogue', [ + m('h2', vnode.attrs.title), + m('p', vnode.attrs.message), + m('div.buttons', [ + m('button', { class: vnode.attrs.yesclass || '', onclick: vnode.attrs.onyes }, vnode.attrs.yes), + m('button', { class: vnode.attrs.noclass || '', onclick: vnode.attrs.onno }, vnode.attrs.no), + ]) + ]) + ) + }, +} + +module.exports = Dialogue diff --git a/app/widgets/fileupload.js b/app/widgets/fileupload.js new file mode 100644 index 0000000..ad47e0b --- /dev/null +++ b/app/widgets/fileupload.js @@ -0,0 +1,68 @@ +const m = require('mithril') +const { uploadMedia } = require('../api/media') + +const FileUpload = { + uploadFile(vnode, event) { + if (!event.target.files[0]) return + vnode.state.updateError(vnode, '') + vnode.state.loading = true + + uploadMedia(event.target.files[0]) + .then(function(res) { + if (vnode.attrs.onupload) { + vnode.attrs.onupload(res) + } + }) + .catch(function(err) { + vnode.state.updateError(vnode, err.message) + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + updateError: function(vnode, error) { + if (vnode.attrs.onerror) { + vnode.attrs.onerror(error) + } else { + vnode.state.error = error + } + }, + + oninit: function(vnode) { + vnode.state.loading = false + vnode.state.error = '' + }, + + view: function(vnode) { + let media = vnode.attrs.media + + return m('fileupload', { + class: vnode.attrs.class || null, + }, [ + m('div.error', { + hidden: !vnode.state.error, + }, vnode.state.error), + (media + ? vnode.attrs.useimg + ? [ m('img', { src: media.large_url }), m('div.showicon')] + : m('a.display.inside', { + href: media.large_url, + style: { + 'background-image': 'url(' + media.medium_url + ')', + }, + }, m('div.showicon')) + : m('div.inside.showbordericon') + ), + m('input', { + accept: 'image/*', + type: 'file', + onchange: this.uploadFile.bind(this, vnode), + }), + (vnode.state.loading ? m('div.loading-spinner') : null), + ]) + } +} + +module.exports = FileUpload diff --git a/app/widgets/newsentry.js b/app/widgets/newsentry.js new file mode 100644 index 0000000..2c1d4a3 --- /dev/null +++ b/app/widgets/newsentry.js @@ -0,0 +1,22 @@ +const m = require('mithril') + +const Newsentry = { + view: function(vnode) { + return m('newsentry', [ + vnode.attrs.media + ? m('a.cover', { + href: vnode.attrs.media.large_url, + }, m('img', { src: vnode.attrs.media.small_url })) + : m('a.cover.nobg'), + m('div.entrycontent', [ + m(m.route.Link, + { href: '/article/' + vnode.attrs.path }, + m('h3', vnode.attrs.name) + ), + m('div.entrymeta', 'Posted ' + vnode.attrs.created_at.replace('T', ' ').split('.')[0]) + ]) + ]) + }, +} + +module.exports = Newsentry diff --git a/config/config.default.json b/config/config.default.json index 88e743d..01fa98e 100644 --- a/config/config.default.json +++ b/config/config.default.json @@ -12,8 +12,10 @@ "password" : "postgres", "database" : "nfpmoe" }, + "connectionslave": null, "migrations": { - } + }, + "acquireConnectionTimeout": 10000 }, "bunyan": { "name": "nfpmoe", @@ -32,11 +34,14 @@ "expiresIn": 604800 } }, + "googleid": "1076074914074-3no1difo1jq3dfug3glfb25pn1t8idud.apps.googleusercontent.com", "sessionsecret": "this-is-session-secret-lol", "bcrypt": 5, "fileSize": 524288000, "upload": { + "port": "2111", + "host": "storage01.nfp.is", "name": "nfpmoe-dev", - "secret": "TJlAbWgpQy0zMGu01XoW" + "secret": "nfpmoe-dev" } } diff --git a/index.mjs b/index.mjs index f699fbd..aa2962d 100644 --- a/index.mjs +++ b/index.mjs @@ -3,12 +3,17 @@ import log from './api/log' // Run the database script automatically. import setup from './api/setup' -setup().then(() => +setup().catch(async (error) => { + log.error({ code: error.code, message: error.message }, 'Error while preparing database') + log.error('Unable to verify database integrity.') + log.warn('Continuing anyways') + // import('./api/config').then(module => { + // log.error(error, 'Error while preparing database') + // log.error({ config: module.default.get() }, 'config used') + // process.exit(1) + // }) +}).then(() => import('./server') -).catch((error) => { - import('./api/config').then(module => { - log.error(error, 'Error while preparing database') - log.error({ config: module.default.get() }, 'config used') - process.exit(1) - }) +).catch(error => { + log.error(error, 'Unknown error starting server') }) diff --git a/migrations/20190219105500_base.js b/migrations/20190219105500_base.js index 84fb88e..4031cb3 100644 --- a/migrations/20190219105500_base.js +++ b/migrations/20190219105500_base.js @@ -29,8 +29,72 @@ exports.up = function up(knex, Promise) { table.text('medium_image') table.text('large_image') table.integer('size') - table.integer('login_id') + table.integer('staff_id') .references('staff.id') + table.boolean('is_deleted') + .notNullable() + .default(false) + table.timestamps() + }), + knex.schema.createTable('files', function(table) { + table.increments() + table.text('filename') + table.text('filetype') + table.integer('size') + table.integer('staff_id') + .references('staff.id') + table.boolean('is_deleted') + .notNullable() + .default(false) + table.timestamps() + }), + knex.schema.createTable('pages', function(table) { + table.increments() + table.integer('staff_id') + .references('staff.id') + table.integer('parent_id') + .references('pages.id') + table.text('name') + table.text('path') + table.text('description') + table.integer('banner_id') + .references('media.id') + .defaultTo(null) + table.integer('media_id') + .references('media.id') + .defaultTo(null) + table.boolean('is_deleted') + .notNullable() + .default(false) + table.timestamps() + }), + knex.schema.createTable('articles', function(table) { + table.increments() + table.integer('staff_id') + .references('staff.id') + table.integer('parent_id') + .references('pages.id') + table.text('name') + table.text('path') + table.text('description') + table.integer('banner_id') + .references('media.id') + .defaultTo(null) + table.integer('media_id') + .references('media.id') + .defaultTo(null) + table.boolean('is_deleted') + .notNullable() + .default(false) + table.timestamps() + }), + knex.schema.createTable('files', function(table) { + table.increments() + table.integer('file_id') + .references('files.id') + table.text('filename') + table.text('filetype') + table.integer('size') table.integer('staff_id') .references('staff.id') table.boolean('is_deleted') diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..075795d --- /dev/null +++ b/nodemon.json @@ -0,0 +1,3 @@ +{ + "ignore": ["app/**", "public/**"] +} \ No newline at end of file diff --git a/package.json b/package.json index 8531fa6..60f6616 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,12 @@ "scripts": { "lint": "eslint .", "start": "node --experimental-modules index.mjs", - "build": "sass app/app.sass public/assets/app.css && browserify -d app/index.js -o public/assets/app.js", + "build": "sass -s compressed app/app.scss public/assets/app.css && browserify -p tinyify --bare --no-browser-field -o public/assets/app.js app/index.js", + "build:check": "browserify -t uglifyify --bare --no-browser-field --list app/index.js", "test": "echo \"Error: no test specified\" && exit 1", "watch:api": "nodemon --experimental-modules index.mjs | bunyan", - "watch:app": "watchify app/index.js -o public/assets/app.js", - "watch:sass": "sass --watch app/app.sass public/assets/app.css", + "watch:app": "watchify -g envify -d app/index.js -o public/assets/app.js", + "watch:sass": "sass --watch app/app.scss public/assets/app.css", "dev": "run-p watch:api watch:app watch:sass", "prod": "npm run build && npm start" }, @@ -29,10 +30,13 @@ "homepage": "https://github.com/nfp-projects/nfp_moe", "dependencies": { "@koa/cors": "^2.2.3", - "bookshelf": "^0.14.2", + "bookshelf": "^0.15.1", "bunyan-lite": "^1.0.1", + "dot": "^1.1.2", "format-link-header": "^2.1.0", + "googleapis": "^42.0.0", "http-errors": "^1.7.2", + "json-mask": "^0.3.8", "jsonwebtoken": "^8.4.0", "knex": "^0.16.3", "koa": "^2.7.0", @@ -43,16 +47,18 @@ "lodash": "^4.17.11", "multer": "^1.4.1", "nconf": "^0.10.0", + "parse-torrent": "^7.0.1", "pg": "^7.8.0", "sharp": "^0.21.3" }, "devDependencies": { "browserify": "^16.2.3", "eslint": "^5.14.1", - "mithril": "^2.0.0-rc.4", + "mithril": "^2.0.3", "nodemon": "^1.18.10", "npm-run-all": "^4.1.5", "sass": "^1.17.0", + "tinyify": "^2.5.1", "watchify": "^3.11.0" } } diff --git a/public/assets/img/favicon.png b/public/assets/img/favicon.png new file mode 100644 index 0000000..667da1d Binary files /dev/null and b/public/assets/img/favicon.png differ diff --git a/public/assets/img/logo.png b/public/assets/img/logo.png new file mode 100644 index 0000000..48ed4d8 Binary files /dev/null and b/public/assets/img/logo.png differ diff --git a/public/index.html b/public/index.html index cf1254d..1ab927f 100644 --- a/public/index.html +++ b/public/index.html @@ -1,6 +1,19 @@ - + + + + NFP Moe + + + + + + - Works +
+ +
+
+ - + \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..14267e9 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / \ No newline at end of file diff --git a/server.mjs b/server.mjs index bc3ed8f..ae33b19 100644 --- a/server.mjs +++ b/server.mjs @@ -7,6 +7,7 @@ import router from './api/router' import Jwt from './api/jwt' import log from './api/log' import { serve } from './api/serve' +import { mask } from './api/middlewares/mask' import { errorHandler } from './api/error/middleware' import { accessChecks } from './api/access/middleware' import ParserMiddleware from './api/parser/middleware' @@ -14,12 +15,13 @@ import ParserMiddleware from './api/parser/middleware' const app = new Koa() const parser = new ParserMiddleware() +app.use(log.logMiddleware()) app.use(errorHandler()) +app.use(mask()) app.use(bodyParser()) app.use(parser.contextParser()) app.use(accessChecks()) app.use(parser.generateLinks()) -app.use(log.logMiddleware()) app.use(Jwt.jwtMiddleware()) app.use(cors({ exposeHeaders: ['link', 'pagination_total'],