Compare commits

..

No commits in common. "master" and "d2090970457939509e4c2c45dad388e2afa9b00f" have entirely different histories.

368 changed files with 8132 additions and 189650 deletions

48
.circleci/config.yml Normal file
View file

@ -0,0 +1,48 @@
version: 2
jobs:
build:
docker:
- image: docker:latest
environment:
- di: "nfpis/nfpmoe"
- dtag: "latest"
- service_name: "nfpmoe"
- target_port: "7030" # The public port
- service_port: "4030" # Container port
working_directory: ~/nfpmoe
steps:
- run:
name: Update and install SSH & Git & sed
command: apk update && apk upgrade && apk add --no-cache bash git openssh sed
- checkout
- setup_remote_docker
- run:
name: Replace version in config
command: |
sed -i "s/circleci_version_number/${CIRCLE_BUILD_NUM}/g" config/config.default.json
- run:
name: Build docker image
command: docker build -t ${di}:build_${CIRCLE_BUILD_NUM} -t ${di}:${CIRCLE_SHA1} -t ${di}:${dtag} .
- run:
name: Push to docker
command: |
docker login -u $DOCKER_USER -p $DOCKER_PASS
docker push ${di} --all-tags
- deploy:
name: Deploy to production
command: |
if [ "${CIRCLE_BRANCH}" != "master" ]; then
echo Not running on master. Exiting.
exit 0
fi
echo "$MASTER_HOST" | base64 -d > ~/.ssh/master_host
echo "$MASTER_KEY" | base64 -d > ~/.ssh/master_key
chmod 600 ~/.ssh/master_key
ssh -p 51120 -i ~/.ssh/master_key -o "UserKnownHostsFile ~/.ssh/master_host" root@212.30.212.2 "docker ${service_name} ${di}:build_${CIRCLE_BUILD_NUM} ${target_port} ${service_port}"
workflows:
version: 2
build_deploy:
jobs:
- build:
context: org-global

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
.circleci
node_modules
public

85
.eslintrc Normal file
View file

@ -0,0 +1,85 @@
{
"parserOptions": {
"ecmaVersion": 9,
"sourceType": "module",
"ecmaFeatures": {
"impliedStrict": true
}
},
"globals": {
"FroalaEditor": "readonly",
"gapi": "readonly",
"m": true
},
"extends": "eslint:recommended",
"env": {
"mocha": true,
"node": true,
"es6": true,
"browser": true
},
"rules": {
"require-await": 0,
"array-callback-return": 2,
"block-scoped-var": 2,
"complexity": ["error", 40],
"eqeqeq": [2, "smart"],
"no-else-return": ["error", { "allowElseIf": false }],
"no-extra-bind": 2,
"no-implicit-coercion": 2,
"no-invalid-this": 2,
"no-loop-func": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-new": 2,
"no-param-reassign": [2, {"props": false}],
"no-return-assign": 2,
"no-return-await": 2,
"no-self-compare": 2,
"no-sequences": 2,
"no-throw-literal": 2,
"no-unmodified-loop-condition": 2,
"no-useless-call": 2,
"no-useless-concat": 2,
"no-useless-return": 2,
"no-void": 2,
"no-warning-comments": 2,
"prefer-promise-reject-errors": 2,
"no-catch-shadow": 2,
"no-shadow": 2,
"no-undef-init": 2,
"no-undefined": 2,
"no-use-before-define": 2,
"no-new-require": 2,
"no-sync": 2,
"array-bracket-newline": [2, "consistent"],
"block-spacing": [2, "always"],
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
"comma-dangle": [2, "always-multiline"],
"comma-spacing": 2,
"comma-style": 2,
"computed-property-spacing": 2,
"eol-last": 2,
"func-call-spacing": 2,
"key-spacing": 2,
"keyword-spacing": 2,
"semi": [2, "never"],
"max-len": [1, 120],
"prefer-const": 0,
"consistent-return": 0,
"no-unused-vars": [
2,
{
"args": "after-used",
"argsIgnorePattern": "next|res|req|vnode"
}
],
"generator-star-spacing": 0,
"global-require": 0,
"import/prefer-default-export": 0,
"no-underscore-dangle": 0,
"strict": 0,
"require-yield": 0
}
}

View file

@ -1,79 +0,0 @@
on:
push:
branches:
- master
jobs:
deploy:
runs-on: arch
steps:
- name: Check out repository code
uses: actions/checkout@v3
- name: Check for new release
run: |
chmod +x ./7zas
echo ""
echo "Checking following projects:"
for f in *; do
[ -d "$f" ] && [ ! -L "$f" ] && [ ! "$f" = "base" ] && echo " * $f";
done
echo ""
for f in *; do
[ ! -d "$f" ] || [ -L "$f" ] || [ "$f" = "base" ] && continue;
echo ""
echo "------------------------------------"
echo ""
echo "checking $f";
cd $f
CURR_VER="$(cat package.json | jq -r .name)_v$(cat package.json | jq -r .version)"
CURR_NAME="$(cat package.json | jq -r .name) v$(cat package.json | jq -r .version)"
echo "Checking https://git.nfp.is/api/v1/repos/${{ github.repository }}/releases for name ${CURR_NAME}"
if curl -s -X GET -H "Authorization: token ${{ secrets.deploytoken }}" https://git.nfp.is/api/v1/repos/${{ github.repository }}/releases | grep -o "\"name\"\:\"${CURR_NAME}\"" > /dev/null; then
echo "Skipping ${{ github.job }} since $CURR_NAME already exists";
cd ..
continue;
fi
echo "New release ${CURR_VER} found, running npm install..."
mv package.json fuck-you-npm-package.json
mv build-package.json package.json
npm install && npm run build
mv package.json build-package.json
mv fuck-you-npm-package.json package.json
../7zas a -xr!*.xcf -mx9 "${CURR_VER}_build-sc.7z" package.json index.mjs api base public
echo "Creating ${CURR_VER} release on gitea"
RELEASE_RESULT=$(curl \
-X POST \
-H "Authorization: token ${{ secrets.deploytoken }}" \
-H "Content-Type: application/json" \
https://git.nfp.is/api/v1/repos/${{ github.repository }}/releases \
-d "{\"tag_name\":\"${CURR_VER}\",\"name\":\"${CURR_NAME}\",\"body\":\"Automatic release from Appveyor from ${{ github.sha }} :\n\n${{ github.event.head_commit.message }}\"}")
RELEASE_ID=$(echo $RELEASE_RESULT | jq -r .id)
echo "Adding ${CURR_VER}_build-sc.7z to release ${RELEASE_ID}"
curl \
-X POST \
-H "Authorization: token ${{ secrets.deploytoken }}" \
-F "attachment=@${CURR_VER}_build-sc.7z" \
https://git.nfp.is/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets
MAN_PORT=$(cat package.json | jq -r .port)
MAN_NAME=$(cat package.json | jq -r .name)
echo "Deplying to production"
echo "curl -X POST http://192.168.93.52:$MAN_PORT/update/$MAN_NAME"
curl -X POST http://192.168.93.52:$MAN_PORT/update/$MAN_NAME
cd ..
done

10
.gitignore vendored
View file

@ -58,7 +58,11 @@ typings/
.env
# Local development config file
config.json
config/config.json
package-lock.json
**/public/**/app.js
**/public/**/admin.js
public/assets/app.js
public/assets/app.css
public/assets/app.css.map
public/assets/admin.js
public/assets/admin.css
public/assets/admin.css.map

BIN
7zas

Binary file not shown.

50
Dockerfile Normal file
View file

@ -0,0 +1,50 @@
###########################
# Angular
###########################
FROM node:12-alpine as build
ENV HOME=/app
COPY package.json $HOME/
COPY app $HOME/app
COPY public $HOME/public
WORKDIR $HOME
RUN apk add --update --no-cache --virtual .build-deps gcc g++ make libc6-compat python git && \
apk add vips-dev fftw-dev build-base --no-cache \
--repository http://dl-3.alpinelinux.org/alpine/v3.10/community \
--repository http://dl-3.alpinelinux.org/alpine/v3.10/main && \
npm install && \
apk del .build-deps gcc g++ make libc6-compat python && \
apk del build-base && \
npm run build
###########################
# Server
###########################
FROM node:12-alpine
ENV HOME=/app
COPY index.mjs package.json $HOME/
WORKDIR $HOME
RUN apk add --update --no-cache --virtual .build-deps gcc g++ make libc6-compat python git && \
apk add vips-dev fftw-dev build-base --no-cache \
--repository http://dl-3.alpinelinux.org/alpine/v3.10/community \
--repository http://dl-3.alpinelinux.org/alpine/v3.10/main && \
npm install --production && \
rm -Rf $HOME/.npm $HOME/.npm $HOME/.config && \
apk del .build-deps gcc g++ make libc6-compat python && \
apk del build-base
COPY api $HOME/api
COPY migrations $HOME/migrations
COPY config $HOME/config
COPY --from=build /app/public $HOME/public
EXPOSE 4030
CMD ["npm", "start"]

View file

@ -1 +1 @@
# nfp_sites
# nfp_moe

29
api/access/index.mjs Normal file
View file

@ -0,0 +1,29 @@
import _ from 'lodash'
const levels = {
Normal: 1,
Manager: 10,
Admin: 100,
}
levels.is = function is(ctx, level) {
if (!_.isInteger(level)) throw new Error('AccessLevelDenied')
if (!ctx.state.user || !ctx.state.user.level) return false
if (ctx.state.user.level !== level) return false
return true
}
levels.atLeast = function atLeast(ctx, level) {
if (!_.isInteger(level)) throw new Error('AccessLevelDenied')
if (!ctx.state.user || !ctx.state.user.level) return false
if (ctx.state.user.level < level) return false
return true
}
levels.ensure = function ensure(ctx, level) {
if (!levels.atLeast(ctx, level)) {
throw new Error('AccessLevelDenied')
}
}
export default levels

33
api/access/middleware.mjs Normal file
View file

@ -0,0 +1,33 @@
import orgAccess from './index.mjs'
export function accessChecks(opts = { }) {
const access = opts.access || orgAccess
return (ctx, next) => {
ctx.state.is = access.is.bind(access, ctx)
ctx.state.atLeast = access.atLeast.bind(access, ctx)
ctx.state.ensure = access.ensure.bind(access, ctx)
return next()
}
}
export function restrict(level = orgAccess.Normal) {
return async (ctx, next) => {
if (!ctx.headers.authorization && !ctx.query.token) {
return ctx.throw(403, 'Authentication token was not found (did you forget to login?)')
}
if (!ctx.state.user || !ctx.state.user.email || !ctx.state.user.level) {
return ctx.throw(403, 'You must be authenticated to access this resource')
}
if (!ctx.state.atLeast(level)) {
return ctx.throw(403, 'You do not have enough access to access this resource')
}
if (next) {
return next()
}
}
}

275
api/article/model.mjs Normal file
View file

@ -0,0 +1,275 @@
import { createPrototype, safeColumns } from '../knex.mjs'
import Media from '../media/model.mjs'
import File from '../file/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,
}
*/
function ArticleItem(data) {
Object.assign(this, data)
}
function Article() {
this.tableName = 'articles'
this.Model = ArticleItem
this.includes = {
staff: Staff.includeHasOne('articles.staff_id', 'id'),
media: Media.includeHasOne('articles.media_id', 'id'),
banner: Media.includeHasOne('articles.banner_id', 'id'),
parent: Page.includeHasOne('articles.parent_id', 'id'),
files: File.includeHasMany('article_id', 'articles.id'),
}
this.publicFields = this.privateFields = safeColumns([
'staff_id',
'parent_id',
'name',
'path',
'description',
'banner_id',
'media_id',
'published_at',
'is_featured',
])
this.init()
}
Article.prototype = createPrototype({
getAll(ctx, where = null, includes = [], orderBy = 'id', limitToday = false) {
return this._getAll(ctx, (qb) => {
if (where) qb.where(where)
if (limitToday) {
qb.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
}
}, includes, orderBy, [])
},
getAllFromPage(ctx, pageId, includes = [], orderBy = 'id', limitToday = false) {
return this._getAll(ctx, (qb) => {
qb = qb.innerJoin('pages', 'articles.parent_id', 'pages.id')
qb.where(subq => {
subq.where('pages.id', pageId)
.orWhere('pages.parent_id', pageId)
})
if (limitToday) {
qb.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
}
return qb
}, includes, orderBy, [])
},
getSingle(id, includes = [], require = true, ctx = null, limitToday = false) {
return this._getSingle(qb => {
qb.where(subq => {
subq.where(this.tableName + '.id', '=', Number(id) || 0)
.orWhere(this.tableName + '.path', '=', id)
})
if (limitToday && (!ctx || !ctx.state.user || ctx.state.user.level < 10)) {
qb.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
}
}, includes, require, ctx)
},
getFeaturedArticle(includes = [], ctx = null) {
return this._getSingle(qb => {
qb.where({ is_featured: true })
.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
.orderBy(this.tableName + '.published_at', 'DESC')
.select(this.knex.raw('1 as __group'))
.limit(1)
}, includes, false, ctx)
},
async getFrontpageArticles(orgPage = 1) {
let page = Math.max(orgPage, 1)
let out = {
featured: null,
items: [],
total: 0,
}
let qFeatured = this.query(qb => {
return qb.where({ is_featured: true })
.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
.orderBy(this.tableName + '.published_at', 'DESC')
.select(this.knex.raw('1 as __group'))
.limit(1)
}, ['staff', 'media', 'banner'])
let qArticles = this.query(qb => {
return qb
.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
.select(this.knex.raw('2 as __group'))
.orderBy(this.tableName + '.published_at', 'DESC')
.limit(10)
.offset((page - 1) * 10)
}, ['staff', 'media', 'banner'], null, qFeatured)
let [articles, total] = await Promise.all([
this.getAllQuery(
this.knex
.unionAll(qFeatured, true)
.unionAll(qArticles, true),
qFeatured
),
this.knex('articles')
.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
.where({ is_deleted: false })
.count('* as count'),
])
out.total = total[0].count
if (articles.length > 0 && articles[0].is_featured) {
out.featured = articles[0]
out.items = articles.slice(1)
} else {
out.items = articles
}
return out
},
setAllUnfeatured() {
return knex('articles')
.where({ is_featured: true })
.update({
is_featured: false,
})
},
/*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')
},
files() {
return this.hasManyFiltered(File, 'file', 'article_id')
.query(qb => {
qb.orderBy('id', 'asc')
})
},*/
/*getAll(ctx, where = {}, withRelated = [], orderBy = 'id', limitToday = false) {
return this.query(qb => {
this.baseQueryAll(ctx, qb, where, orderBy)
if (limitToday) {
qb.where('published_at', '<=', (new Date()).toISOString())
}
})
.fetchPage({
pageSize: ctx.state.pagination.perPage,
page: ctx.state.pagination.page,
withRelated,
ctx: ctx,
})
.then(result => {
ctx.state.pagination.total = result.pagination.rowCount
return result
})
},
getSingle(id, withRelated = [], require = true, ctx = null, limitToday = false) {
return this.query(qb => {
qb.where(subq => {
subq.where({ id: Number(id) || 0 })
.orWhere({ path: id })
})
if (limitToday && (!ctx || !ctx.state.user || ctx.state.user.level < 10)) {
qb.where('published_at', '<=', (new Date()).toISOString())
}
})
.fetch({ require, withRelated, ctx })
},
async getFeatured(withRelated = [], ctx = null) {
let data = await this.query(qb => {
qb.where({ is_featured: true })
.where('published_at', '<=', (new Date()).toISOString())
})
.fetch({ require: false, withRelated, ctx })
if (!data) {
data = await this.query(qb => {
qb.where('published_at', '<=', (new Date()).toISOString())
.whereNotNull('banner_id')
})
.fetch({ require: false, withRelated, ctx })
}
return data
},
getAllFromPage(ctx, pageId, withRelated = [], orderBy = 'id', limitToday = false) {
return this.query(qb => {
this.baseQueryAll(ctx, qb, {}, orderBy)
qb.leftOuterJoin('pages', 'articles.parent_id', 'pages.id')
qb.where(subq => {
subq.where('pages.id', pageId)
.orWhere('pages.parent_id', pageId)
})
if (limitToday) {
qb.where('published_at', '<=', (new Date()).toISOString())
}
qb.select('articles.*')
})
.fetchPage({
pageSize: ctx.state.pagination.perPage,
page: ctx.state.pagination.page,
withRelated,
ctx: ctx,
})
.then(result => {
ctx.state.pagination.total = result.pagination.rowCount
return result
})
},
setAllUnfeatured() {
return bookshelf.knex('articles')
.where({ is_featured: true })
.update({
is_featured: false,
})
},
getFrontpageArticles(page = 1) {
return this.query(qb => {
qb.orderBy('published_at', 'DESC')
.where('published_at', '<=', (new Date()).toISOString())
})
.fetchPage({
pageSize: 10,
page: page,
withRelated: ['files', 'media', 'banner', 'parent', 'staff'],
})
},*/
})
const articleInstance = new Article()
// Hook into includes for Page
// Page.addInclude('news', articleInstance.includeHasMany('parent_id', 'pages.id'))
export default articleInstance

77
api/article/routes.mjs Normal file
View file

@ -0,0 +1,77 @@
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)
ctx.body = await this.Article.getAll(ctx, { }, ctx.state.filter.includes, ctx.query.sort || '-published_at')
}
/** 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)
}
/** GET: /api/articles/public */
async getPublicAllArticles(ctx) {
await this.security.ensureIncludes(ctx)
ctx.body = await this.Article.getAll(ctx, { }, ctx.state.filter.includes, ctx.query.sort || '-published_at', true)
}
/** GET: /api/pages/:pageId/articles/public */
async getPublicAllPageArticles(ctx) {
await this.security.ensureIncludes(ctx)
ctx.body = await this.Article.getAllFromPage(ctx, ctx.params.pageId, ctx.state.filter.includes, ctx.query.sort || '-published_at', true)
}
/** GET: /api/articles/public/:id */
async getPublicSingleArticle(ctx) {
await this.security.ensureIncludes(ctx)
ctx.body = await this.Article.getSingle(ctx.params.id, ctx.state.filter.includes, true, ctx, true)
}
/** POST: /api/articles */
async createArticle(ctx) {
await this.security.validUpdate(ctx)
if (!ctx.request.body.staff_id) {
ctx.request.body.staff_id = ctx.state.user.id
}
ctx.body = await this.Article.create(ctx.request.body)
}
/** PUT: /api/articles/:id */
async updateArticle(ctx) {
await this.security.validUpdate(ctx)
if (ctx.request.body.is_featured) {
await Article.setAllUnfeatured()
}
let article = await this.Article.updateSingle(ctx, ctx.params.id, ctx.request.body)
ctx.body = article
}
/** DELETE: /api/articles/:id */
async removeArticle(ctx) {
await this.Article.updateSingle(ctx, ctx.params.id, { is_deleted: true })
ctx.status = 204
}
}

44
api/article/security.mjs Normal file
View file

@ -0,0 +1,44 @@
import filter from '../filter.mjs'
const requiredFields = [
'name',
'path',
]
const validFields = [
'name',
'path',
'staff_id',
'description',
'parent_id',
'media_id',
'banner_id',
'published_at',
'is_featured',
]
export async function ensureIncludes(ctx) {
let out = filter(ctx.state.filter.includes, ['staff', 'media', 'parent', 'banner', 'files'])
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(', ')}`)
}
if (ctx.request.body.published_at) {
ctx.request.body.published_at = new Date(ctx.request.body.published_at)
}
}

View file

@ -0,0 +1,32 @@
import Staff from '../staff/model.mjs'
import Jwt from '../jwt.mjs'
export default class AuthHelper {
constructor(opts = {}) {
Object.assign(this, {
Staff: opts.Staff || Staff,
jwt: opts.jwt || new Jwt(),
})
}
async loginStaff(ctx) {
let staff
try {
staff = await this.Staff
.getSingleQuery(
this.Staff.query(qb => qb.where({ email: ctx.request.body.username }), [], ['*']),
true
)
await this.Staff.compare(ctx.request.body.password, staff.password)
} catch (err) {
if (err.message === 'EmptyResponse' || err.message === 'PasswordMismatch') {
ctx.throw(422, 'The email or password did not match')
}
throw err
}
return this.jwt.createToken(staff.id, staff.email, staff.level)
}
}

View file

@ -0,0 +1,32 @@
import Staff from '../staff/model.mjs'
import Jwt from '../jwt.mjs'
import * as security from './security.mjs'
import AuthHelper from './helper.mjs'
export default class AuthRoutes {
constructor(opts = {}) {
Object.assign(this, {
helper: opts.helper || new AuthHelper(),
Staff: opts.Staff || Staff,
jwt: opts.jwt || new Jwt(),
security: opts.security || security,
})
}
/*
* POST /api/login/user - Authenticate a user using password login
*
* @body {string} username - Username
* @body {string} password - Password
* @returns
*
* { token: 'Authentication token' }
*/
async loginUser(ctx) {
this.security.isValidLogin(ctx, ctx.request.body)
let token = await this.helper.loginStaff(ctx)
ctx.body = { token }
}
}

View file

@ -0,0 +1,18 @@
export function isValidLogin(ctx, body) {
if (!body.username) {
ctx.throw(422, 'Body was missing property username')
}
if (!body.password) {
ctx.throw(422, 'Body was missing property password')
}
if (typeof body.password !== 'string') {
ctx.throw(422, 'Property password must be a string')
}
if (typeof body.username !== 'string') {
ctx.throw(422, 'Property username must be a string')
}
}

90
api/config.mjs Normal file
View file

@ -0,0 +1,90 @@
import _ from 'lodash'
import nconf from 'nconf-lite'
import { readFileSync } from 'fs'
// Helper method for global usage.
nconf.inTest = () => nconf.get('NODE_ENV') === 'test'
// Config follow the following priority check order:
// 1. Enviroment variables
// 2. package.json
// 3. config/config.json
// 4. config/config.default.json
// Load enviroment variables as first priority
nconf.env({
separator: '__',
whitelist: [
'DATABASE_URL',
'NODE_ENV',
'server__port',
'server__host',
'knex__connection__host',
'knex__connection__user',
'knex__connection__database',
'knex__connection__password',
'knex__connectionslave__host',
'knex__connectionslave__user',
'knex__connectionslave__database',
'knex__connectionslave__password',
'upload__baseurl',
'upload__port',
'upload__host',
'upload__name',
'upload__secret',
'bunyan__name',
'frontend__url',
'jwt__secret',
'sessionsecret',
'bcrypt',
'name',
'NODE_VERSION',
],
parseValues: true,
})
// Load package.json for name and such
let pckg = JSON.parse(readFileSync('./package.json'))
pckg = _.pick(pckg, ['name', 'version', 'description', 'author', 'license', 'homepage'])
if (nconf.get('DATABASE_URL')) {
pckg.knex = { connection: nconf.get('DATABASE_URL') }
}
// Load overrides as second priority
nconf.overrides(pckg)
// Load any overrides from the appropriate config file
let configFile = 'config/config.json'
/* istanbul ignore else */
if (nconf.get('NODE_ENV') === 'test') {
configFile = 'config/config.test.json'
}
/* istanbul ignore if */
if (nconf.get('NODE_ENV') === 'production') {
configFile = 'config/config.production.json'
}
nconf.file('main', configFile)
// Load defaults
nconf.file('default', 'config/config.default.json')
// Final sanity checks
/* istanbul ignore if */
if (typeof global.it === 'function' & !nconf.inTest()) {
// eslint-disable-next-line no-console
console.log('Critical: potentially running test on production enviroment. Shutting down.')
process.exit(1)
}
export default nconf

34
api/defaults.mjs Normal file
View file

@ -0,0 +1,34 @@
// taken from isobject npm library
function isObject(val) {
return val != null && typeof val === 'object' && Array.isArray(val) === false
}
export default function defaults(options, def) {
let out = { }
if (options) {
Object.keys(options || {}).forEach(key => {
out[key] = options[key]
if (Array.isArray(out[key])) {
out[key] = out[key].map(item => {
if (isObject(item)) return defaults(item)
return item
})
} else if (out[key] && typeof out[key] === 'object') {
out[key] = defaults(options[key], def && def[key])
}
})
}
if (def) {
Object.keys(def).forEach(function(key) {
if (typeof out[key] === 'undefined') {
out[key] = def[key]
}
})
}
return out
}

31
api/error/middleware.mjs Normal file
View file

@ -0,0 +1,31 @@
import createError from 'http-errors'
export function errorHandler() {
return async (ctx, next) => {
try {
await next()
} catch (org) {
let error = org
if (error.message === 'EmptyResponse') {
error = createError(404)
} else if (error.message === 'AccessLevelDenied') {
error = createError(403)
}
if (!error.status) {
ctx.log.error(error)
error = createError(500, `Unknown error occured: ${error.message}`)
ctx.log.warn(error)
} else {
ctx.log.warn(error)
}
ctx.status = error.status
ctx.body = {
status: error.status,
message: error.message,
body: error.body || { },
}
}
}
}

59
api/file/model.mjs Normal file
View file

@ -0,0 +1,59 @@
import { createPrototype, safeColumns } from '../knex.mjs'
import config from '../config.mjs'
/*
File model:
{
filename,
filetype,
size,
path,
staff_id,
article_id,
is_deleted,
created_at,
updated_at,
*url,
*magnet,
}
*/
const baseUrl = config.get('upload:baseurl')
function FileItem(data) {
Object.assign(this, data)
this.url = `${baseUrl}${this.path}`
let meta = this.meta
if (!meta.torrent) {
this.magnet = ''
} else {
this.magnet = 'magnet:?'
+ 'xl=' + this.size
+ '&dn=' + encodeURIComponent(meta.torrent.name)
+ '&xt=urn:btih:' + meta.torrent.hash
+ meta.torrent.announce.map(item => ('&tr=' + encodeURIComponent(item))).join('')
}
}
function File() {
this.tableName = 'files'
this.Model = FileItem
this.publicFields = this.privateFields = safeColumns([
'article_id',
'filename',
'filetype',
'path',
'size',
'staff_id',
'meta',
])
this.init()
}
File.prototype = createPrototype({
})
export default new File()

61
api/file/routes.mjs Normal file
View file

@ -0,0 +1,61 @@
import { readFile } from 'fs'
import parseTorrent from 'parse-torrent'
import config from '../config.mjs'
import File from './model.mjs'
import * as multer from '../multer.mjs'
import { uploadFile } from '../media/upload.mjs'
import Jwt from '../jwt.mjs'
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'))
let meta = {}
if (result.originalname.endsWith('.torrent')) {
let fileContent = await new Promise((res, rej) => {
readFile(result.path, (err, data) => {
if (err) return rej(err)
res(data)
})
})
let torrent = parseTorrent(fileContent)
meta = {
torrent: {
name: torrent.name,
announce: torrent.announce,
hash: torrent.infoHash,
files: torrent.files.map(file => ({ name: file.name, size: file.length })),
},
}
}
let file = await this.uploadFile(token, result.path)
ctx.body = await this.File.create({
filename: result.originalname,
filetype: result.mimetype,
path: file.path,
article_id: ctx.params.articleId,
size: result.size,
staff_id: ctx.state.user.id,
meta: meta,
})
}
async removeFile(ctx) {
await this.File.updateSingle(ctx, ctx.params.id, { is_deleted: true })
ctx.status = 200
}
}

8
api/filter.mjs Normal file
View file

@ -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 []
}

9
api/index/routes.mjs Normal file
View file

@ -0,0 +1,9 @@
export default class IndexRoutes {
constructor(opts = {}) {
this.indexBody = ''
}
async sendIndex(ctx) {
ctx.body = this.indexBody
}
}

68
api/jwt.mjs Normal file
View file

@ -0,0 +1,68 @@
import _ from 'lodash'
import jwt from 'jsonwebtoken'
import koaJwt from 'koa-jwt'
import config from './config.mjs'
export default class Jwt {
constructor(opts = {}) {
Object.assign(this, {
jwt: opts.jwt || jwt,
})
}
sign(value, appendSecret = '', opts) {
let secret = config.get('jwt:secret') + appendSecret
let options = _.defaults(opts, config.get('jwt:options'))
if (options.expiresIn === null) {
delete options.expiresIn
}
return this.jwt.sign(value, secret, options)
}
signDirect(value, secret) {
return this.jwt.sign(value, secret)
}
verify(token, appendSecret = '') {
let secret = config.get('jwt:secret') + appendSecret
return new Promise((resolve, reject) =>
this.jwt.verify(token, secret, (err, res) => {
if (err) return reject(err)
resolve(res)
})
)
}
decode(token) {
return this.jwt.decode(token)
}
createToken(id, email, level, opts) {
return this.sign({
id: id,
email: email,
level: level,
}, email, opts)
}
static jwtMiddleware() {
return koaJwt({
getToken: ctx => {
if (ctx.request.header.authorization) {
return ctx.request.header.authorization.split(' ')[1]
}
if (ctx.query.token) {
return ctx.query.token
}
return null
},
secret: (header, payload) =>
`${config.get('jwt:secret')}${payload.email}`,
passthrough: true,
})
}
}

414
api/knex.mjs Normal file
View file

@ -0,0 +1,414 @@
import _ from 'lodash'
import knexCore from 'knex-core'
import config from './config.mjs'
import defaults from './defaults.mjs'
import log from './log.mjs'
const knex = knexCore(config.get('knex'))
const functionMap = new Map()
let joinPostFix = 1
// Helper method to create models
export function createPrototype(opts) {
return defaults(opts, {
knex: knex,
init() {
if (!this.tableName) throw new Error('createModel was called with missing tableName')
if (!this.Model) throw new Error('createModel was called with missing Model')
if (!this.includes) this.includes = {}
if (!this.publicFields) throw new Error(this.tableName + ' was missing publicFields')
if (!this.privateFields) throw new Error(this.tableName + ' was missing privateFields')
this.__includeFields = this.publicFields.map(x => x)
this.publicFields = this.publicFields.map(x => `${this.tableName}.${x} as ${this.tableName}.${x}`)
if (this.publicFields !== this.privateFields) {
this.privateFields = this.privateFields.map(x => `${this.tableName}.${x} as ${this.tableName}.${x}`)
}
},
addInclude(name, include) {
this.includes[name] = include
},
_includeBase(type, subq) {
let self = this
let postfix = '_' + joinPostFix++
let table = this.tableName + postfix
return {
type: type,
postfix: postfix,
table: table,
fields: this.__includeFields.map(x => `${table}.${x} as ${table}.${x}`),
model: self,
qb: function(qb) {
return subq(self, table, qb)
}
}
},
includeHasOne(source_id, target_id) {
return this._includeBase(1, function(self, table, qb) {
return qb.leftOuterJoin(`${self.tableName} as ${table}`, function() {
this.on(source_id, '=', table + '.' + target_id)
.andOn(table + '.is_deleted', '=', knex.raw('false'))
})
})
},
includeHasMany(source_id, target_id, subq = null) {
return this._includeBase(2, function(self, table, qb) {
return qb.leftOuterJoin(`${self.tableName} as ${table}`, function() {
this.on(table + '.' + source_id, '=', target_id)
.andOn(table + '.is_deleted', '=', knex.raw('false'))
if (subq) {
subq(this, self)
}
})
})
},
async getAllQuery(query, queryContext = null) {
let context = (queryContext || query).queryContext()
if (!context.tables) throw new Error('getAll was called before query')
let tables = context.tables
let tableMap = new Map(tables)
let data = await query
if (data.length === 0) {
return data
}
let keys = Object.keys(data[0])
for (let i = 0; i < keys.length; i++) {
let parts = keys[i].split('.')
if (parts.length === 1) {
if (parts[0] !== '__group') {
tables[0][1].builder += `'${parts[0]}': data.${keys[i]},`
}
} else {
let builder = tableMap.get(parts[0])
if (builder) {
builder.builder += `'${parts[1]}': data['${keys[i]}'],`
}
}
}
tableMap.forEach(table => {
table.builder += '}'
table.fn = functionMap.get(table.builder)
if (!table.fn) {
table.fn = new Function('data', table.builder)
functionMap.set(table.builder, table.fn)
}
})
let out = []
let includesTwoSet = new Set()
for (let i = 0; i < data.length; i++) {
let baseItem = null
for (var t = 0; t < tables.length; t++) {
let table = tables[t][1]
let propertyName = table.include
let formattedData = table.fn(data[i])
if (!formattedData) {
if (propertyName && baseItem[propertyName] === undefined) {
console.log('emptying')
baseItem[propertyName] = (table.includeType.type === 1 ? null : [])
}
continue
}
let row = new table.Model(table.fn(data[i]))
let rowId = row.id
if (table.isRoot && data[i].__group) {
rowId = data[i].__group + '_' + row.id
}
let foundItem = table.map.get(rowId)
// If we didn't find this item, current table moble or joined table model
// is new, therefore we need to create it
if (!foundItem) {
// Create a reference to it if we're dealing with the root object
if (table.isRoot) {
baseItem = row
}
table.map.set(rowId, row)
if (table.isRoot) {
// Add item to root array since this is a root array
out.push(baseItem)
} else if (table.includeType.type === 1) {
// This is a single instance join for the root mode,
// set it directly to the root
baseItem[propertyName] = row
} else if (table.includeType.type === 2) {
// This is an array instance for the root model. Time to dig in.
/* if (!baseItem[propertyName]) {
baseItem[propertyName] = []
} */
if (!includesTwoSet.has(baseItem.id + '_' + propertyName + '_' + row.id)) {
baseItem[propertyName].push(row)
includesTwoSet.add(baseItem.id + '_' + propertyName + '_' + row.id)
}
}
} else if (table.isRoot) {
baseItem = foundItem
} else if (propertyName) {
if (table.includeType.type === 1 && !baseItem[propertyName]) {
baseItem[propertyName] = foundItem
} else if (table.includeType.type === 2 && !includesTwoSet.has(baseItem.id + '_' + propertyName + '_' + row.id)) {
/* if (!baseItem[propertyName]) {
baseItem[propertyName] = []
} */
baseItem[propertyName].push(foundItem)
includesTwoSet.add(baseItem.id + '_' + propertyName + '_' + row.id)
}
}
}
}
return out
},
async getSingleQuery(query, require = true) {
let data = await this.getAllQuery(query)
if (data.length) return data[0]
if (require) throw new Error('EmptyResponse')
return null
},
query(qb, includes = [], customFields = null, parent = null, pagination = null, paginationOrderBy = null) {
let query
let fields
if (customFields === true) {
fields = this.publicFields
} else {
fields = customFields ? customFields : this.publicFields
}
if (pagination) {
query = knex.with(this.tableName, subq => {
subq.select(this.tableName + '.*')
.from(this.tableName)
.where(this.tableName + '.is_deleted', '=', 'false')
qb(subq)
subq.orderBy(pagination.orderProperty, pagination.sort)
.limit(pagination.perPage)
.offset((pagination.page - 1) * pagination.perPage)
}).from(this.tableName)
} else {
query = knex(this.tableName).where(this.tableName + '.is_deleted', '=', 'false')
qb(query)
}
let tables = parent && parent.queryContext().tables || []
let tableMap = new Map(tables)
if (!tables.length) {
tables.push([this.tableName, {
builder: 'return {',
fn: null,
map: new Map(),
Model: this.Model,
isRoot: true,
include: null,
includeType: {},
}])
}
query.select(fields)
for (let i = 0; i < includes.length; i++) {
let includeType = this.includes[includes[i]]
if (!includeType) {
throw new Error(`Model ${this.tableName} was missing includes ${includes[i]}`)
}
includeType.qb(query).select(includeType.fields)
if (tableMap.has(includeType.table)) {
continue
}
if (includeType.type === 1) {
tables[0][1].builder += `${includes[i]}: null,`
} else {
tables[0][1].builder += `${includes[i]}: [],`
}
let newTable = [
includeType.table,
{
builder: `if (!data.id && !data['${includeType.table}.id']) {/*console.log('${includeType.table}', data.id, data['${includeType.table}.id']);*/return null;} return {`,
fn: null,
map: new Map(),
isRoot: false,
Model: includeType.model.Model,
include: includes[i],
includeType: includeType,
}
]
tables.push(newTable)
tableMap.set(newTable[0], newTable[1])
}
if (pagination) {
query.orderBy(pagination.orderProperty, pagination.sort)
}
query.queryContext({ tables: tables })
return query
},
async _getAll(ctx, subq, includes = [], orderBy = 'id') {
let orderProperty = orderBy
let sort = 'ASC'
if (orderProperty[0] === '-') {
orderProperty = orderProperty.slice(1)
sort = 'DESC'
}
ctx.state.pagination.sort = sort
ctx.state.pagination.orderProperty = orderProperty
let [data, total] = await Promise.all([
this.getAllQuery(this.query(qb => {
let qbnow = qb
if (subq) {
qbnow = subq(qb) || qb
}
return qbnow
}, includes, null, null, ctx.state.pagination)),
(() => {
let qb = this.knex(this.tableName)
if (subq) {
qb = subq(qb) || qb
}
qb.where(this.tableName + '.is_deleted', '=', false)
return qb.count('* as count')
})(),
])
ctx.state.pagination.total = total[0].count
return data
},
getAll(ctx, subq, includes = [], orderBy = 'id') {
return this._getAll(ctx, subq, includes, orderBy)
},
_getSingle(subq, includes = [], require = true, ctx = null) {
return this.getSingleQuery(this.query(qb => {
return qb
.where(qb => {
if (subq) subq(qb)
})
}, includes), require)
},
getSingle(id, includes = [], require = true, ctx = null) {
return this._getSingle(qb => qb.where(this.tableName + '.id', '=', Number(id) || 0 ), includes, require, ctx)
},
async updateSingle(ctx, id, body) {
// Fetch the item in question, making sure it exists
let item = await this.getSingle(id, [], true, ctx)
// Paranoia checking
if (typeof(item.id) !== 'number') throw new Error('Item was missing id')
body.updated_at = new Date()
// Update our item in the database
let out = await knex(this.tableName)
.where({ id: item.id })
// Map out the 'as' from the private fields so it returns a clean
// response in the body
.update(body, this.privateFields.map(x => x.split('as')[0]))
// More paranoia checking
if (out.length < 1) throw new Error('Updated item returned empty result')
return out[0]
},
/**
* Create new entry in the database.
*
* @param {Object} data - The values the new item should have
* @return {Object} The resulting object
*/
async create(body) {
body.created_at = new Date()
body.updated_at = new Date()
let out = await knex(this.tableName)
// Map out the 'as' from the private fields so it returns a clean
// response in the body
.insert(body, this.privateFields.map(x => x.split('as')[0]))
// More paranoia checking
if (out.length < 1) throw new Error('Updated item returned empty result')
return out[0]
},
/**
* Apply basic filtering to query builder object. Basic filtering
* applies stuff like custom filtering in the query and ordering and other stuff
*
* @param {Request} ctx - API Request object
* @param {QueryBuilder} qb - knex query builder object to apply filtering on
* @param {Object} [where={}] - Any additional filtering
* @param {string} [orderBy=id] - property to order result by
* @param {Object[]} [properties=[]] - Properties allowed to filter by from query
*/
_baseQueryAll(ctx, qb, where = {}, orderBy = 'id', properties = []) {
let orderProperty = orderBy
let sort = 'ASC'
if (orderProperty[0] === '-') {
orderProperty = orderProperty.slice(1)
sort = 'DESC'
}
qb.where(where)
_.forOwn(ctx.state.filter.where(properties), (value, key) => {
if (key.startsWith('is_')) {
qb.where(key, value === '0' ? false : true)
} else {
qb.where(key, 'LIKE', `%${value}%`)
}
})
_.forOwn(ctx.state.filter.whereNot(properties), (value, key) => {
if (key.startsWith('is_')) {
qb.whereNot(key, value === '0' ? false : true)
} else {
qb.where(key, 'NOT LIKE', `%${value}%`)
}
})
qb.orderBy(orderProperty, sort)
},
/*async getSingle(id, require = true, ctx = null) {
let where = { id: Number(id) || 0 }
let data = await knex(this.tableName).where(where).first(this.publicFields)
if (!data && require) throw new Error('EmptyResponse')
return data
},*/
})
}
export function safeColumns(extra) {
return ['id', /*'is_deleted',*/ 'created_at', 'updated_at'].concat(extra || [])
}
/*shelf.safeColumns = (extra) =>
['id', 'is_deleted', 'created_at', 'updated_at'].concat(extra || [])*/

28
api/log.mjs Normal file
View file

@ -0,0 +1,28 @@
import bunyan from 'bunyan-lite'
import config from './config.mjs'
import * as defaults from './defaults.mjs'
// Clone the settings as we will be touching
// on them slightly.
let settings = defaults.default(config.get('bunyan'))
// Replace any instance of 'process.stdout' with the
// actual reference to the process.stdout.
for (let i = 0; i < settings.streams.length; i++) {
/* istanbul ignore else */
if (settings.streams[i].stream === 'process.stdout') {
settings.streams[i].stream = process.stdout
}
}
// Create our logger.
const log = bunyan.createLogger(settings)
export default log
log.logMiddleware = () =>
(ctx, next) => {
ctx.log = log
return next()
}

133
api/media/model.mjs Normal file
View file

@ -0,0 +1,133 @@
import path from 'path'
import { createPrototype, safeColumns } from '../knex.mjs'
import config from '../config.mjs'
/*
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 baseUrl = config.get('upload:baseurl')
function MediaItem(data) {
Object.assign(this, data)
this.small_url = `${baseUrl}${this.small_image}`
this.medium_url = `${baseUrl}${this.medium_image}`
this.large_url = `${baseUrl}${this.large_image}`
this.small_url_avif = this.small_image_avif ? `${baseUrl}${this.small_image_avif}` : null
this.medium_url_avif = this.small_image_avif ? `${baseUrl}${this.medium_image_avif}` : null
this.large_url_avif = this.small_image_avif ? `${baseUrl}${this.large_image_avif}` : null
this.link = `${baseUrl}${this.org_image}`
}
function Media() {
this.tableName = 'media'
this.Model = MediaItem
this.publicFields = this.privateFields = safeColumns([
'filename',
'filetype',
'small_image',
'medium_image',
'large_image',
'org_image',
'size',
'staff_id',
'small_image_avif',
'medium_image_avif',
'large_image_avif',
])
this.init()
}
Media.prototype = createPrototype({
baseUrl: baseUrl,
getSubUrl(input, size, type = 'jpg') {
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}.${type}`
},
})
/*
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')}`
},
small_url_avif() {
if (!this.get('small_image_avif')) return null
return `${Media.baseUrl}${this.get('small_image_avif')}`
},
medium_url_avif() {
if (!this.get('small_image_avif')) return null
return `${Media.baseUrl}${this.get('medium_image_avif')}`
},
large_url_avif() {
if (!this.get('small_image_avif')) return null
return `${Media.baseUrl}${this.get('large_image_avif')}`
},
link() {
return `${Media.baseUrl}${this.get('org_image')}`
},
url() {
return `${Media.baseUrl}${this.get('medium_image')}`
},
thumb() {
return `${Media.baseUrl}${this.get('small_image')}`
},
},
}, {
baseUrl: config.get('upload:baseurl'),
getSubUrl(input, size, type = 'jpg') {
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}.${type}`
},
})*/
export default new Media()

101
api/media/resize.mjs Normal file
View file

@ -0,0 +1,101 @@
import sharp from 'sharp'
import Media from './model.mjs'
export default class Resizer {
constructor(opts = {}) {
Object.assign(this, {
Media: opts.Media || Media,
sharp: opts.sharp || sharp,
})
}
createSmall(input, format = 'jpg') {
let output = this.Media.getSubUrl(input, 'small', format === 'avif' ? 'avif' : 'jpg')
let sized = this.sharp(input)
.resize(500, 500, {
fit: sharp.fit.inside,
withoutEnlargement: true,
})
if (format === 'avif') {
return sized
.avif({
quality: 80,
speed: 5,
})
.toFile(output)
.then(() => output)
} else {
return sized
.jpeg({
quality: 93,
})
.toFile(output)
.then(() => output)
}
}
createMedium(input, height, format = 'jpg') {
let output = this.Media.getSubUrl(input, 'medium', format === 'avif' ? 'avif' : 'jpg')
let sized = this.sharp(input)
.resize(800, height || 800, {
fit: height && sharp.fit.cover || sharp.fit.inside,
withoutEnlargement: true,
})
if (format === 'avif') {
return sized
.avif({
quality: 80,
speed: 5,
})
.toFile(output)
.then(() => output)
} else {
return sized
.jpeg({
quality: 93,
})
.toFile(output)
.then(() => output)
}
}
createLarge(input, format = 'jpg') {
let output = this.Media.getSubUrl(input, 'large', format === 'avif' ? 'avif' : 'jpg')
let sized = this.sharp(input)
.resize(1280, 1280, {
fit: sharp.fit.inside,
withoutEnlargement: true,
})
if (format === 'avif') {
return sized
.avif({
quality: 85,
speed: 5,
})
.toFile(output)
.then(() => output)
} else {
return sized
.jpeg({
quality: 93,
})
.toFile(output)
.then(() => output)
}
}
autoRotate(input) {
const output = `${input}_2.jpg`
return this.sharp(input)
.rotate()
.toFile(output)
.then(() => output)
}
}

70
api/media/routes.mjs Normal file
View file

@ -0,0 +1,70 @@
import config from '../config.mjs'
import Media from './model.mjs'
import * as multer from '../multer.mjs'
import Resizer from './resize.mjs'
import { uploadFile } from './upload.mjs'
import Jwt from '../jwt.mjs'
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 height = null
if (ctx.query.height) {
height = Number(ctx.query.height)
}
let smallPath = await this.resize.createSmall(result.path)
let mediumPath = await this.resize.createMedium(result.path, height)
let largePath = await this.resize.createLarge(result.path)
let smallPathAvif = await this.resize.createSmall(result.path, 'avif')
let mediumPathAvif = await this.resize.createMedium(result.path, height, 'avif')
let largePathAvif = await this.resize.createLarge(result.path, 'avif')
let token = this.jwt.signDirect({ site: config.get('upload:name') }, config.get('upload:secret'))
let [org, small, medium, large, smallAvif, mediumAvif, largeAvif] = await Promise.all([
this.uploadFile(token, result.path),
this.uploadFile(token, smallPath),
this.uploadFile(token, mediumPath),
this.uploadFile(token, largePath),
this.uploadFile(token, smallPathAvif),
this.uploadFile(token, mediumPathAvif),
this.uploadFile(token, largePathAvif),
])
ctx.body = await this.Media.create({
filename: result.originalname,
filetype: result.mimetype,
small_image: small.path,
medium_image: medium.path,
large_image: large.path,
small_image_avif: smallAvif.path,
medium_image_avif: mediumAvif.path,
large_image_avif: largeAvif.path,
org_image: org.path,
size: result.size,
staff_id: ctx.state.user.id,
})
}
async getAllMedia(ctx) {
ctx.body = await this.Media.getAll(ctx)
}
async removeMedia(ctx) {
await this.Media.updateSingle(ctx, ctx.params.id, { is_deleted: true })
ctx.status = 200
}
}

77
api/media/upload.mjs Normal file
View file

@ -0,0 +1,77 @@
import http from 'http'
import path from 'path'
import fs from 'fs'
import config from '../config.mjs'
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
}

18
api/middlewares/mask.mjs Normal file
View file

@ -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))
}
}

15
api/multer.mjs Normal file
View file

@ -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)
})
})
}

125
api/page/model.mjs Normal file
View file

@ -0,0 +1,125 @@
import { createPrototype, safeColumns } from '../knex.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,
}
*/
function PageItem(data) {
Object.assign(this, data)
this.children = []
}
function Page() {
this.tableName = 'pages'
this.Model = PageItem
this.includes = {
media: Media.includeHasOne('pages.media_id', 'id'),
banner: Media.includeHasOne('pages.banner_id', 'id'),
}
this.publicFields = this.privateFields = safeColumns([
'staff_id',
'parent_id',
'name',
'path',
'description',
'banner_id',
'media_id',
])
this.init()
}
Page.prototype = createPrototype({
/* includes: {
staff: Staff.includeHasOne('staff_id', 'id'),
}, */
/*banner() {
return this.belongsTo(Media, 'banner_id')
},
parent() {
return this.belongsTo(Page, 'parent_id')
},
children() {
return this.hasManyFiltered(Page, 'children', 'parent_id')
.query(qb => {
qb.orderBy('name', 'ASC')
})
},
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, includes = [], require = true, ctx = null) {
return this._getSingle(qb => {
qb.where(subq => {
subq.where(this.tableName + '.id', '=', Number(id) || 0)
.orWhere(this.tableName + '.path', '=', id)
})
}, includes, require, ctx)
},
async getTree() {
let items = await this.getAllQuery(this.query(
qb => qb.orderBy('name', 'ASC'),
[],
['parent_id', 'id', 'name', 'path']
))
let out = []
let map = new Map()
for (let i = 0; i < items.length; i++) {
if (!items[i].parent_id) {
out.push(items[i])
}
map.set(items[i].id, items[i])
}
for (let i = 0; i < items.length; i++) {
if (items[i].parent_id && map.has(items[i].parent_id)) {
map.get(items[i].parent_id).children.push(items[i])
}
}
return out
},
})
const pageInstance = new Page()
pageInstance.addInclude('children', pageInstance.includeHasMany('parent_id', 'pages.id'))
pageInstance.addInclude('parent', pageInstance.includeHasOne('pages.parent_id', 'id'))
export default pageInstance

53
api/page/routes.mjs Normal file
View file

@ -0,0 +1,53 @@
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/pagetree */
async getPageTree(ctx) {
ctx.body = await this.Page.getTree()
}
/** GET: /api/pages */
async getAllPages(ctx) {
await this.security.ensureIncludes(ctx)
ctx.body = await this.Page.getAll(ctx, null, ctx.state.filter.includes, 'name')
}
/** 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.updateSingle(ctx, ctx.params.id, ctx.request.body)
ctx.body = page
}
/** DELETE: /api/pages/:id */
async removePage(ctx) {
await this.Page.updateSingle(ctx, ctx.params.id, { is_deleted: true })
ctx.status = 204
}
}

37
api/page/security.mjs Normal file
View file

@ -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(', ')}`)
}
}

35
api/parser/middleware.mjs Normal file
View file

@ -0,0 +1,35 @@
import format from 'format-link-header'
import * as pagination from './pagination.mjs'
export default class ParserMiddleware {
constructor(opts = {}) {
Object.assign(this, {
pagination: opts.pagination || pagination,
format: opts.format || format,
})
}
contextParser() {
return (ctx, next) => {
ctx.state.pagination = this.pagination.parsePagination(ctx)
ctx.state.filter = this.pagination.parseFilter(ctx)
return next()
}
}
generateLinks() {
return async (ctx, next) => {
await next()
if (ctx.state.pagination.total > 0) {
ctx.set('Link', this.format(this.pagination.generateLinks(ctx, ctx.state.pagination.total)))
}
if (ctx.state.pagination.total != null) {
ctx.set('pagination_total', ctx.state.pagination.total)
}
}
}
}

129
api/parser/pagination.mjs Normal file
View file

@ -0,0 +1,129 @@
import _ from 'lodash'
import { format } from 'url'
import config from '../config.mjs'
function limit(value, min, max, fallback) {
let out = parseInt(value, 10)
if (!out) {
out = fallback
}
if (out < min) {
out = min
}
if (out > max) {
out = max
}
return out
}
export function parsePagination(ctx) {
let out = {
perPage: limit(ctx.query.perPage, 1, 1500, 1250),
page: limit(ctx.query.page, 1, Number.MAX_SAFE_INTEGER, 1),
}
Object.keys(ctx.query).forEach(item => {
if (item.startsWith('perPage.')) {
let name = item.substring(8)
out[name] = {
perPage: limit(ctx.query[`perPage.${name}`], 1, 1500, 1250),
page: limit(ctx.query[`page.${name}`], 1, Number.MAX_SAFE_INTEGER, 1),
}
}
})
return out
}
export function parseFilter(ctx) {
let where
let whereNot
where = _.omitBy(ctx.query, test => test[0] === '!')
whereNot = _.pickBy(ctx.query, test => test[0] === '!')
whereNot = _.transform(
whereNot,
(result, value, key) => (result[key] = value.slice(1))
)
return {
where: pick => _.pick(where, pick),
whereNot: pick => _.pick(whereNot, pick),
includes: (ctx.query.includes && ctx.query.includes.split(',')) || [],
}
}
export function generateLinks(ctx, total) {
let out = []
let base = _(ctx.query)
.omit(['page'])
.transform((res, val, key) => res.push(`${key}=${val}`), [])
.value()
if (!ctx.query.perPage) {
base.push(`perPage=${ctx.state.pagination.perPage}`)
}
// let protocol = ctx.protocol
// if (config.get('frontend:url').startsWith('https')) {
// protocol = 'https'
// }
let proto = ctx.protocol
if (config.get('frontend:url').startsWith('https')) {
proto = 'https'
}
let first = format({
protocol: proto,
host: ctx.host,
pathname: ctx.path,
})
first += `?${base.join('&')}`
// Add the current page first
out.push({
rel: 'current',
title: `Page ${ctx.query.page || 1}`,
url: `${first}`,
})
// Then add any previous pages if we can
if (ctx.state.pagination.page > 1) {
out.push({
rel: 'previous',
title: 'Previous',
url: `${first}&page=${ctx.state.pagination.page - 1}`,
})
out.push({
rel: 'first',
title: 'First',
url: `${first}&page=1`,
})
}
// Then add any next pages if we can
if ((ctx.state.pagination.perPage * (ctx.state.pagination.page - 1)) + ctx.state.pagination.perPage < total) {
out.push({
rel: 'next',
title: 'Next',
url: `${first}&page=${ctx.state.pagination.page + 1}`,
})
out.push({
rel: 'last',
title: 'Last',
url: `${first}&page=${Math.ceil(total / ctx.state.pagination.perPage)}`,
})
}
return out
}

57
api/router.mjs Normal file
View file

@ -0,0 +1,57 @@
/* eslint max-len: 0 */
import Router from 'koa-router'
import access from './access/index.mjs'
import { restrict } from './access/middleware.mjs'
import AuthRoutes from './authentication/routes.mjs'
// import MediaRoutes from './media/routes.mjs'
// import FileRoutes from './file/routes.mjs'
import PageRoutes from './page/routes.mjs'
import ArticleRoutes from './article/routes.mjs'
import StaffRoutes from './staff/routes.mjs'
const router = new Router()
// API Authentication
const authentication = new AuthRoutes()
router.post('/api/login/user', authentication.loginUser.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/pagetree', page.getPageTree.bind(page))
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', restrict(access.Manager), article.getAllArticles.bind(article))
router.get('/api/articles/public', article.getPublicAllArticles.bind(article))
router.get('/api/articles/public/:id', article.getPublicSingleArticle.bind(article))
router.get('/api/pages/:pageId/articles/public', article.getPublicAllPageArticles.bind(article))
router.get('/api/articles/:id', restrict(access.Manager), 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))
const staff = new StaffRoutes()
router.get('/api/staff', restrict(access.Manager), staff.getAllStaff.bind(staff))
router.get('/api/staff/:id', restrict(access.Admin), staff.getSingleStaff.bind(staff))
router.post('/api/staff', restrict(access.Admin), staff.createStaff.bind(staff))
router.put('/api/staff/:id', restrict(access.Admin), staff.updateStaff.bind(staff))
router.del('/api/staff/:id', restrict(access.Admin), staff.removeStaff.bind(staff))
export default router

61
api/serve.mjs Normal file
View file

@ -0,0 +1,61 @@
import send from 'koa-send'
import defaults from './defaults.mjs'
import access from './access/index.mjs'
import { restrict } from './access/middleware.mjs'
import { serveIndex } from './serveindex.mjs'
import config from './config.mjs'
const restrictAdmin = restrict(access.Manager)
export function serve(docRoot, pathname, options = {}) {
options.root = docRoot
return async (ctx, next) => {
let opts = defaults({}, options)
if (ctx.request.method === 'OPTIONS') return
let filepath = ctx.path.replace(pathname, '')
if (filepath === '/') {
filepath = '/index.html'
}
if (filepath.endsWith('.jpg')
|| filepath.endsWith('.png')
|| filepath.endsWith('.js')
|| filepath.endsWith('.css')
|| filepath.endsWith('.avif')
|| filepath.endsWith('.svg')) {
if (filepath.indexOf('admin') === -1) {
opts = defaults({ maxage: 2592000 * 1000 }, opts)
}
if (filepath.endsWith('.avif')) {
ctx.type = 'image/avif'
}
}
if (filepath === '/index.html') {
return serveIndex(ctx, '/')
}
if (filepath.indexOf('admin') >= 0
&& (filepath.indexOf('js') >= 0
|| filepath.indexOf('css') >= 0)) {
if (filepath.indexOf('.map') === -1 && filepath.indexOf('.scss') === -1) {
await restrictAdmin(ctx)
ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate')
} else if (config.get('NODE_ENV') !== 'development') {
ctx.status = 404
return
}
}
return send(ctx, filepath, opts).catch((er) => {
if (er.code === 'ENOENT' && er.status === 404) {
ctx.type = null
return serveIndex(ctx, filepath)
// return send(ctx, '/index.html', options)
}
})
}
}

222
api/serveindex.mjs Normal file
View file

@ -0,0 +1,222 @@
import { readFileSync } from 'fs'
import dot from 'dot'
import striptags from 'striptags'
import config from './config.mjs'
import Page from './page/model.mjs'
// import Article from '../app/article/model.mjs'
import Article from './article/model.mjs'
const body = readFileSync('./public/index.html').toString()
const bodyTemplate = dot.template(body)
const frontend = config.get('frontend:url')
function mapArticle(trim = false, x, includeBanner = false, includeFiles = true) {
return {
id: x.id,
published_at: x.published_at,
path: x.path,
description: x.description,
name: x.name,
staff: x.staff && ({
id: x.staff.id,
fullname: x.staff.fullname,
}) || null,
media: x.media && ({
link: !trim && x.media.link || null,
large_url: x.media.large_url,
large_url_avif: x.media.large_url_avif,
medium_url: x.media.medium_url,
medium_url_avif: x.media.medium_url_avif,
small_url: x.media.small_url,
small_url_avif: x.media.small_url_avif,
}) || null,
banner: x.banner && includeBanner && ({
large_url: x.banner.large_url,
large_url_avif: x.banner.large_url_avif,
medium_url: x.banner.medium_url,
medium_url_avif: x.banner.medium_url_avif,
small_url: x.banner.small_url,
small_url_avif: x.banner.small_url_avif,
}) || null,
parent: x.parent && ({
id: x.parent.id,
name: x.parent.name,
path: x.parent.path,
}),
files: x.files && includeFiles && x.files.map(f => ({
filename: f.filename,
url: f.url,
magnet: f.magnet,
meta: f.meta.torrent && ({
torrent: {
name: f.meta.torrent.name,
files: f.meta.torrent.files.map(tf => {
if (trim && f.meta.torrent.files.length > 4) return 1
return {
name: tf.name,
size: tf.size,
}
}),
},
}) || {},
})) || [],
}
}
function mapPage(x) {
return {
id: x.id,
created_at: x.created_at,
path: x.path,
description: x.description,
name: x.name,
media: x.media && ({
link: x.media.link,
large_url: x.media.large_url,
large_url_avif: x.media.large_url_avif,
medium_url: x.media.medium_url,
medium_url_avif: x.media.medium_url_avif,
small_url: x.media.small_url,
small_url_avif: x.media.small_url_avif,
}) || null,
parent: x.parent && ({
id: x.parent.id,
name: x.parent.name,
path: x.parent.path,
}),
banner: x.banner && ({
large_url: x.banner.large_url,
large_url_avif: x.banner.large_url_avif,
medium_url: x.banner.medium_url,
medium_url_avif: x.banner.medium_url_avif,
small_url: x.banner.small_url,
small_url_avif: x.banner.small_url_avif,
}) || null,
children: x.children && x.children.map(f => ({
id: f.id,
path: f.path,
name: f.name,
})) || [],
}
}
export async function serveIndex(ctx, path) {
let tree = null
let data = null
let subdata = null
let links = null
let featured = null
let url = frontend + ctx.request.url
let image = frontend + '/assets/img/heart.png'
let image_avif = frontend + '/assets/img/heart.png'
let title = 'NFP Moe - Anime/Manga translation group'
let description = 'Small fansubbing and scanlation group translating and encoding our favourite shows from Japan.'
try {
tree = await Page.getTree()
let currPage = Number(ctx.query.page || '1')
if (path === '/') {
let frontpage = await Article.getFrontpageArticles(currPage)
featured = frontpage.featured
data = frontpage.items.map(mapArticle.bind(null, true))
if (frontpage.total > currPage * 10) {
links = {
first: currPage > 1 ? { page: 1, title: 'First' } : null,
previous: currPage > 1 ? { page: currPage - 1, title: 'Previous' } : null,
current: { title: 'Page ' + currPage },
next: { page: currPage + 1, title: 'Next' },
last: { page: Math.ceil(frontpage.total / 10), title: 'Last' },
}
} else {
links = {
first: currPage > 1 ? { page: 1, title: 'First' } : null,
previous: currPage > 1 ? { page: currPage - 1, title: 'Previous' } : null,
current: { title: 'Page 1' },
}
}
if (currPage > 1) {
links.previous = { page: currPage - 1, title: 'Previous' }
links.first = { page: 1, title: 'First' }
}
} else if (path.startsWith('/article/') || path.startsWith('/page/')) {
let id = path.split('/')[2]
if (id) {
if (path.startsWith('/article/')) {
data = await Article.getSingle(id, ['media', 'parent', 'banner', 'files', 'staff'], false, null, true)
if (data) {
data = mapArticle(false, data)
}
} else {
data = await Page.getSingle(id, ['media', 'banner', 'children', 'parent'])
data = mapPage(data)
ctx.state.pagination = {
perPage: 10,
page: currPage,
}
subdata = await Article.getAllFromPage(ctx, data.id, ['files', 'media'], '-published_at', true)
subdata = subdata.map(mapArticle.bind(null, true))
if (ctx.state.pagination.total > currPage * 10) {
links = {
first: currPage > 1 ? { page: 1, title: 'First' } : null,
previous: currPage > 1 ? { page: currPage - 1, title: 'Previous' } : null,
current: { title: 'Page ' + currPage },
next: { page: currPage + 1, title: 'Next' },
last: { page: Math.ceil(ctx.state.pagination.total / 10), title: 'Last' },
}
} else {
links = {
first: currPage > 1 ? { page: 1, title: 'First' } : null,
previous: currPage > 1 ? { page: currPage - 1, title: 'Previous' } : null,
current: { title: 'Page 1' },
}
}
}
if (data) {
if (data.media) {
image = data.media.large_url
image_avif = data.media.large_url_avifl
} else if (data.banner) {
image = data.banner.large_url
image_avif = data.banner.large_url_avifl
}
if (data.description) {
description = striptags(data.description)
}
if (data.parent) {
title = data.name + ' - ' + data.parent.name + ' - NFP Moe'
} else {
title = data.name + ' - NFP Moe'
}
}
}
}
if (!featured) {
featured = await Article.getFeaturedArticle(['media', 'banner'])
}
if (featured) {
featured = mapArticle(true, featured, true, false)
}
} catch (e) {
ctx.log.error(e)
data = null
links = null
}
ctx.body = bodyTemplate({
v: config.get('CIRCLECI_VERSION'),
tree: JSON.stringify(tree),
data: JSON.stringify(data),
subdata: JSON.stringify(subdata),
links: JSON.stringify(links),
featured: JSON.stringify(featured),
url: url,
image: image,
image_avif: image_avif,
title: title,
description: description,
})
ctx.set('Content-Length', Buffer.byteLength(ctx.body))
ctx.set('Cache-Control', 'max-age=0')
ctx.set('Content-Type', 'text/html; charset=utf-8')
}

39
api/server.mjs Normal file
View file

@ -0,0 +1,39 @@
import Koa from 'koa-lite'
import bodyParser from 'koa-bodyparser'
import cors from '@koa/cors'
import config from './config.mjs'
import router from './router.mjs'
import Jwt from './jwt.mjs'
import log from './log.mjs'
import { serve } from './serve.mjs'
import { mask } from './middlewares/mask.mjs'
import { errorHandler } from './error/middleware.mjs'
import { accessChecks } from './access/middleware.mjs'
import ParserMiddleware from './parser/middleware.mjs'
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(Jwt.jwtMiddleware())
app.use(cors({
exposeHeaders: ['link', 'pagination_total'],
credentials: true,
}))
app.use(router.routes())
app.use(router.allowedMethods())
app.use(serve('./public', '/public'))
const server = app.listen(
config.get('server:port'),
() => log.info(`Server running on port ${config.get('server:port')}`)
)
export default server

28
api/setup.mjs Normal file
View file

@ -0,0 +1,28 @@
import _ from 'lodash'
import config from './config.mjs'
import log from './log.mjs'
import knex from 'knex-core'
// This is important for setup to run cleanly.
let knexConfig = _.cloneDeep(config.get('knex'))
knexConfig.pool = { min: 1, max: 1 }
let knexSetup = knex(knexConfig)
export default function setup() {
log.info(knexConfig, 'Running database integrity scan.')
return knexSetup.migrate.latest({
directory: './migrations',
})
.then((result) => {
if (result[1].length === 0) {
return log.info('Database is up to date')
}
for (let i = 0; i < result[1].length; i++) {
log.info('Applied migration from', result[1][i].substr(result[1][i].lastIndexOf('\\') + 1))
}
return knexSetup.destroy()
})
}

82
api/staff/model.mjs Normal file
View file

@ -0,0 +1,82 @@
import { createPrototype, safeColumns } from '../knex.mjs'
import bcrypt from 'bcrypt'
/*import config from '../config.mjs'*/
/* Staff model:
{
id,
username,
password,
fullname,
is_deleted,
level,
created_at,
updated_at,
}
*/
function StaffItem(data) {
Object.assign(this, data)
}
function Staff() {
this.tableName = 'staff'
this.Model = StaffItem
this.privateFields = safeColumns(['fullname','email','level',])
this.publicFields = ['id', 'fullname']
this.init()
}
Staff.prototype = createPrototype({
hash(password) {
return new Promise((resolve, reject) =>
bcrypt.hash(password, config.get('bcrypt'), (err, hashed) => {
if (err) return reject(err)
resolve(hashed)
})
)
},
compare(password, hashed) {
return new Promise((resolve, reject) =>
bcrypt.compare(password, hashed, (err, res) => {
if (err || !res) return reject(new Error('PasswordMismatch'))
resolve()
})
)
},
_getSingle(subq, includes = [], require = true, ctx = null) {
return this.getSingleQuery(this.query(qb => {
return qb
.where(qb => {
if (subq) subq(qb)
})
}, includes, this.privateFields), require)
},
/* getAll(ctx, where = {}, withRelated = [], orderBy = 'id') {
return this.query(qb => {
this.baseQueryAll(ctx, qb, where, orderBy)
qb.select(bookshelf.safeColumns([
'fullname',
'email',
'level',
]))
})
.fetchPage({
pageSize: ctx.state.pagination.perPage,
page: ctx.state.pagination.page,
withRelated,
ctx: ctx,
})
.then(result => {
ctx.state.pagination.total = result.pagination.rowCount
return result
})
}, */
})
export default new Staff()

44
api/staff/routes.mjs Normal file
View file

@ -0,0 +1,44 @@
import Staff from './model.mjs'
import * as security from './security.mjs'
export default class StaffRoutes {
constructor(opts = {}) {
Object.assign(this, {
Staff: opts.Staff || Staff,
security: opts.security || security,
})
}
/** GET: /api/staff */
async getAllStaff(ctx) {
ctx.body = await this.Staff.getAll(ctx, null, [])
}
/** GET: /api/staff/:id */
async getSingleStaff(ctx) {
ctx.body = await this.Staff.getSingle(ctx.params.id, [], true, ctx)
}
/** POST: /api/staff */
async createStaff(ctx) {
await this.security.validUpdate(ctx)
ctx.body = await this.Staff.create(ctx.request.body)
}
/** PUT: /api/staff/:id */
async updateStaff(ctx) {
await this.security.validUpdate(ctx)
let staff = await this.Staff.updateSingle(ctx, ctx.params.id, ctx.request.body)
ctx.body = staff
}
/** DELETE: /api/staff/:id */
async removeStaff(ctx) {
await this.Staff.updateSingle(ctx, ctx.params.id, { is_deleted: true })
ctx.status = 204
}
}

21
api/staff/security.mjs Normal file
View file

@ -0,0 +1,21 @@
import filter from '../filter.mjs'
import Staff from './model.mjs'
const validFields = [
'fullname',
'email',
'password',
'level',
]
export async function validUpdate(ctx) {
let out = filter(Object.keys(ctx.request.body), validFields)
if (out.length > 0) {
ctx.throw(422, `Body had following invalid properties: ${out.join(', ')}`)
}
if (ctx.request.body.password) {
ctx.request.body.password = await Staff.hash(ctx.request.body.password)
}
}

67
app/_common.scss Normal file
View file

@ -0,0 +1,67 @@
$primary-bg: #01579b;
$primary-fg: white;
$primary-fg-url: #FFC7C7;
$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;
$table-fg: #333;
$border: #ccc;
$border-fg: black;
$border-fg-url: #8f3c00;
$title-fg: #555;
$meta-fg: #757575; // #999
$meta-light-fg: #999999;
$main-bg: white;
$main-fg: black;
$input-bg: white;
$input-border: #333;
$input-fg: black;
$newsitem-bg: transparent;
$newsitem-border: none;
/* Dark theme */
$dark_primary-bg: #013b68;
$dark_primary-fg: white;
$dark_primary-fg-url: #FFC7C7;
$dark_primary-light-bg: #28518B;
$dark_primary-light-fg: white;
$dark_primary-dark-bg: #002f6c;
$dark_primary-dark-fg: white;
$dark_secondary-bg: #e05e00;
$dark_secondary-fg: black;
$dark_secondary-light-bg: #ffad42;
$dark_secondary-light-fg: black;
$dark_secondary-dark-bg: #e05e00;
$dark_secondary-dark-fg: white;
$dark_secondary-darker-fg: #fe791b;
$dark_table-fg: #333;
$dark_border: #343536;
$dark_border-fg: #d7dadc;;
$dark_border-fg-url: #e05e00;
$dark_title-fg: #808080;
$dark_meta-fg: hsl(0, 0%, 55%);
$dark_meta-light-fg: #999999;
$dark_main-bg: black;
$dark_main-fg: #d7dadc;
$dark_input-bg: #272729;
$dark_input-border: #343536;
$dark_input-fg: white;
$dark_newsitem-bg: #1a1a1b;
$dark_newsitem-border: 1px solid #343536;

12
app/admin.js Normal file
View file

@ -0,0 +1,12 @@
const EditPage = require('./admin/editpage')
const AdminPages = require('./admin/pages')
const AdminArticles = require('./admin/articles')
const EditArticle = require('./admin/editarticle')
const AdminStaffList = require('./admin/stafflist')
const EditStaff = require('./admin/editstaff')
window.adminRoutes = {
pages: [AdminPages, EditPage],
articles: [AdminArticles, EditArticle],
staff: [AdminStaffList, EditStaff],
}

79
app/admin.scss Normal file
View file

@ -0,0 +1,79 @@
@import './_common';
.error {
font-size: 0.8em;
color: $secondary-dark-bg;
font-weight: bold;
padding-bottom: 20px;
}
$bordercolor: $primary-bg;
$headcolor: $primary-light-bg;
$headtext: $primary-light-fg;
.admin-wrapper table {
width: calc(100% - 20px);
margin: 10px;
border: solid 1px $bordercolor;
border-collapse: collapse;
border-spacing: 0;
font-size: 0.8em;
thead th {
background-color: $headcolor;
border: solid 1px $bordercolor;
color: $headtext;
padding: 10px;
text-align: left;
}
tbody td {
text-align: left;
border: solid 1px $bordercolor;
color: $table-fg;
padding: 10px;
}
a,
a:visited,
a:hover {
text-decoration: none;
color: $secondary-dark-bg;
font-weight: bold;
}
button {
color: $secondary-dark-bg;
background: transparent;
border: 1px solid $secondary-dark-bg;
}
td.right,
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 'admin/admin';
@import 'widgets/admin';
.darkmodeon {
.maincontainer .admin-wrapper {
color: $main-fg;
}
.error {
color: $dark_secondary-dark-bg;
}
}

40
app/admin/admin.scss Normal file
View file

@ -0,0 +1,40 @@
.admin-wrapper {
flex-grow: 2;
display: flex;
flex-direction: column;
background: $primary-bg;
padding: 0 20px 50px;
}
.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';
@import 'staff';

141
app/admin/articles.js Normal file
View file

@ -0,0 +1,141 @@
const Article = require('../api/article')
const pagination = require('../api/pagination')
const Dialogue = require('../widgets/dialogue')
const Pages = require('../widgets/pages')
const AdminArticles = {
oninit: function(vnode) {
this.error = ''
this.lastpage = m.route.param('page') || '1'
this.articles = []
this.removeArticle = null
this.fetchArticles(vnode)
},
onupdate: function(vnode) {
if (m.route.param('page') && m.route.param('page') !== this.lastpage) {
this.fetchArticles(vnode)
}
},
fetchArticles: function(vnode) {
this.loading = true
this.links = null
this.lastpage = m.route.param('page') || '1'
document.title = 'Articles Page ' + this.lastpage + ' - Admin NFP Moe'
return pagination.fetchPage(Article.getAllArticlesPagination({
per_page: 20,
page: this.lastpage,
includes: ['parent', 'staff'],
}))
.then(function(result) {
vnode.state.articles = result.data
vnode.state.links = result.links
})
.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
Article.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 --',
}
}
let className = ''
if (new Date() < new Date(article.published_at)) {
className = 'rowhidden'
} else if (article.is_featured) {
className = 'rowfeatured'
}
return [
m('tr', { class: className }, [
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.published_at.replace('T', ' ').split('.')[0]),
m('td.right', article.staff && article.staff.fullname || 'Admin'),
m('td.right', m('button', { onclick: function() { vnode.state.removeArticle = article } }, 'Remove')),
]),
]
},
view: function(vnode) {
return [
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),
(this.loading
? m('div.loading-spinner.full')
: m('table', [
m('thead',
m('tr', [
m('th', 'Title'),
m('th', 'Page'),
m('th', 'Path'),
m('th.right', 'Publish'),
m('th.right', 'By'),
m('th.right', 'Actions'),
])
),
m('tbody', this.articles.map(AdminArticles.drawArticle.bind(this, vnode))),
])
),
m(Pages, {
base: '/admin/articles',
links: this.links,
}),
]),
]),
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

130
app/admin/articles.scss Normal file
View file

@ -0,0 +1,130 @@
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: 150px;
}
}
fileupload.cover {
align-self: center;
min-width: 178px;
}
form {
padding: 0 40px 20px;
textarea {
height: 300px;
}
label.slim {
font-size: 0.7em;
}
input.slim {
font-size: 0.8em;
padding: 2px 5px;
}
.loading-spinner {
height: 300px;
position: relative;
}
button.submit {
margin-left: 20px;
}
}
table {
tr.rowhidden td {
background: #e6e6e6;
}
tr.rowfeatured td {
background: hsl(120, 60%, 85%);
}
}
h5 {
margin-bottom: 20px;
}
& > .loading-spinner {
width: 240px;
height: 50px;
position: relative;
&.full {
width: 100%;
}
}
.fileupload {
align-self: center;
padding: 0.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;
}
}
files {
align-items: stretch;
width: 100%;
display: flex;
flex-direction: column;
padding: 10px 40px 0;
text-align: left;
h4 {
font-size: 1.1em;
font-weight: bold;
padding: 0 5px 5px;
margin-bottom: 10px;
border-bottom: 1px solid $border;
}
}
}
table span.subarticle {
padding: 0 5px;
}

374
app/admin/editarticle.js Normal file
View file

@ -0,0 +1,374 @@
const Authentication = require('../authentication')
const FileUpload = require('../widgets/fileupload')
const Staff = require('../api/staff')
const Froala = require('./froala')
const Page = require('../api/page')
const File = require('../api/file')
const Fileinfo = require('../widgets/fileinfo')
const Article = 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.froala = null
this.loadedFroala = Froala.loadedFroala
this.staffers = []
Staff.getAllStaff()
.then(function(result) {
vnode.state.staffers = result
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
m.redraw()
})
if (!this.loadedFroala) {
Froala.createFroalaScript()
.then(function() {
vnode.state.loadedFroala = true
m.redraw()
})
}
this.fetchArticle(vnode)
},
onupdate: function(vnode) {
if (this.lastid !== m.route.param('id')) {
this.fetchArticle(vnode)
if (this.lastid === 'add') {
m.redraw()
}
}
},
fetchArticle: function(vnode) {
this.lastid = m.route.param('id')
this.loading = this.lastid !== 'add'
this.creating = this.lastid === 'add'
this.loadingFile = false
this.error = ''
this.article = {
name: '',
path: '',
description: '',
media: null,
banner: null,
files: [],
is_featured: false,
published_at: new Date(new Date().setFullYear(3000)).toISOString(),
}
this.editedPath = false
this.loadedFroala = Froala.loadedFroala
if (this.lastid !== 'add') {
Article.getArticle(this.lastid)
.then(function(result) {
vnode.state.editedPath = true
vnode.state.article = result
EditArticle.parsePublishedAt(vnode, null)
document.title = 'Editing: ' + result.name + ' - Admin NFP Moe'
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
vnode.state.loading = false
if (vnode.state.froala) {
vnode.state.froala.html.set(vnode.state.article.description)
}
m.redraw()
})
} else {
EditArticle.parsePublishedAt(vnode, null)
document.title = 'Create Article - Admin NFP Moe'
if (vnode.state.froala) {
vnode.state.froala.html.set(this.article.description)
}
}
},
parsePublishedAt: function(vnode, date) {
vnode.state.article.published_at = ((date && date.toISOString() || vnode.state.article.published_at).split('.')[0]).substr(0, 16)
},
updateValue: function(name, e) {
if (name === 'is_featured') {
this.article[name] = e.currentTarget.checked
} else {
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
}
},
updateStaffer: function(e) {
this.article.staff_id = Number(e.currentTarget.value)
},
mediaUploaded: function(type, media) {
this.article[type] = media
},
mediaRemoved: function(type) {
this.article[type] = null
},
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'
} else {
this.error = ''
}
if (this.error) return
this.article.description = vnode.state.froala && vnode.state.froala.html.get() || this.article.description
if (this.article.description) {
this.article.description = this.article.description.replace(/<p[^>]+data-f-id="pbf"[^>]+>[^>]+>[^>]+>[^>]+>/, '')
}
this.loading = true
let promise
if (this.article.id) {
promise = Article.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,
published_at: new Date(this.article.published_at),
is_featured: this.article.is_featured,
staff_id: this.article.staff_id,
})
} else {
promise = Article.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,
published_at: new Date(this.article.published_at),
is_featured: this.article.is_featured,
staff_id: this.article.staff_id,
})
}
promise.then(function(res) {
if (vnode.state.article.id) {
res.media = vnode.state.article.media
res.banner = vnode.state.article.banner
res.files = vnode.state.article.files
vnode.state.article = res
EditArticle.parsePublishedAt(vnode, null)
} 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: function(vnode, event) {
if (!event.target.files[0]) return
vnode.state.error = ''
vnode.state.loadingFile = true
File.uploadFile(this.article.id, event.target.files[0])
.then(function(res) {
vnode.state.article.files.push(res)
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
event.target.value = null
vnode.state.loadingFile = false
m.redraw()
})
},
getFlatTree: function() {
let out = [{id: null, name: '-- Frontpage --'}]
Page.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
},
getStaffers: function() {
if (!this.article.staff_id) {
this.article.staff_id = 1
}
let out = []
this.staffers.forEach(function(item) {
out.push({ id: item.id, name: item.fullname })
})
return out
},
view: function(vnode) {
const showPublish = new Date(this.article.published_at) > new Date()
const parents = this.getFlatTree()
const staffers = this.getStaffers()
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, {
height: 300,
onupload: this.mediaUploaded.bind(this, 'banner'),
onerror: function(e) { vnode.state.error = e },
ondelete: this.mediaRemoved.bind(this, 'banner'),
media: this.article && this.article.banner,
}),
m(FileUpload, {
class: 'cover',
useimg: true,
onupload: this.mediaUploaded.bind(this, 'media'),
ondelete: this.mediaRemoved.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.slim', 'Path'),
m('input.slim', {
type: 'text',
value: this.article.path,
oninput: this.updateValue.bind(this, 'path'),
}),
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', 'Published at'),
m('input', {
type: 'datetime-local',
value: this.article.published_at,
oninput: this.updateValue.bind(this, 'published_at'),
}),
m('label', 'Published by'),
m('select', {
onchange: this.updateStaffer.bind(this),
}, staffers.map(function(item) { return m('option', { value: item.id, selected: item.id === vnode.state.article.staff_id }, item.name) })),
m('label', 'Make featured'),
m('input', {
type: 'checkbox',
checked: this.article.is_featured,
oninput: this.updateValue.bind(this, 'is_featured'),
}),
m('div.loading-spinner', { hidden: this.loadedFroala }),
m('div', [
m('input', {
type: 'submit',
value: 'Save',
}),
showPublish
? m('button.submit', { onclick: function() { vnode.state.article.published_at = new Date().toISOString() }}, 'Publish')
: null,
]),
]),
this.article.files.length
? m('files', [
m('h4', 'Files'),
this.article.files.map(function(item) { return m(Fileinfo, { file: item }) }),
])
: null,
this.article.id
? m('div.fileupload', [
'Add file',
m('input', {
accept: '*',
type: 'file',
onchange: this.uploadFile.bind(this, vnode),
}),
(vnode.state.loadingFile ? m('div.loading-spinner') : null),
])
: null,
]),
])
)
},
}
module.exports = EditArticle

246
app/admin/editpage.js Normal file
View file

@ -0,0 +1,246 @@
const Authentication = require('../authentication')
const FileUpload = require('../widgets/fileupload')
const Froala = require('./froala')
const Page = 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.froala = null
this.loadedFroala = Froala.loadedFroala
if (!this.loadedFroala) {
Froala.createFroalaScript()
.then(function() {
vnode.state.loadedFroala = true
m.redraw()
})
}
this.fetchPage(vnode)
},
onupdate: function(vnode) {
if (this.lastid !== m.route.param('id')) {
this.fetchPage(vnode)
if (this.lastid === 'add') {
m.redraw()
}
}
},
fetchPage: function(vnode) {
this.lastid = m.route.param('id')
this.loading = this.lastid !== 'add'
this.creating = this.lastid === 'add'
this.error = ''
this.page = {
name: '',
path: '',
description: '',
media: null,
}
this.editedPath = false
if (this.lastid !== 'add') {
Page.getPage(this.lastid)
.then(function(result) {
vnode.state.editedPath = true
vnode.state.page = result
document.title = 'Editing: ' + result.name + ' - Admin NFP Moe'
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
vnode.state.loading = false
m.redraw()
})
} else {
document.title = 'Create Page - Admin NFP Moe'
}
},
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
},
fileRemoved: function(type) {
this.page[type] = null
},
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'
} else {
this.error = ''
}
if (this.error) return
this.page.description = vnode.state.froala ? vnode.state.froala.html.get() : this.page.description
if (this.page.description) {
this.page.description = this.page.description.replace(/<p[^>]+data-f-id="pbf"[^>]+>[^>]+>[^>]+>[^>]+>/, '')
}
this.loading = true
let promise
if (this.page.id) {
promise = Page.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 || null,
media_id: this.page.media && this.page.media.id || null,
})
} else {
promise = Page.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 || null,
media_id: this.page.media && this.page.media.id || null,
})
}
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
console.log(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()
})
return false
},
view: function(vnode) {
const parents = [{id: null, name: '-- Frontpage --'}].concat(Page.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'),
m(m.route.Link, { href: '/admin/pages/add' }, 'Create new 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'),
ondelete: this.fileRemoved.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'),
ondelete: this.fileRemoved.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

View file

@ -1,5 +1,6 @@
const Staff = require('../api/staff')
const EditStaff = {
/*
oninit: function(vnode) {
this.fetchStaff(vnode)
},
@ -16,10 +17,10 @@ const EditStaff = {
this.creating = this.lastid === 'add'
this.error = ''
this.staff = {
name: '',
fullname: '',
email: '',
password: '',
rank: 10,
level: 10,
}
if (this.lastid !== 'add') {
@ -27,7 +28,7 @@ const EditStaff = {
.then(function(result) {
vnode.state.editedPath = true
vnode.state.staff = result
document.title = 'Editing: ' + result.name + ' - Admin NFP Moe'
document.title = 'Editing: ' + result.fullname + ' - Admin NFP Moe'
})
.catch(function(err) {
vnode.state.error = err.message
@ -41,13 +42,13 @@ const EditStaff = {
}
},
updateValue: function(key, e) {
this.staff[key] = e.currentTarget.value
updateValue: function(fullname, e) {
this.staff[fullname] = e.currentTarget.value
},
save: function(vnode, e) {
e.preventDefault()
if (!this.staff.name) {
if (!this.staff.fullname) {
this.error = 'Fullname is missing'
} else if (!this.staff.email) {
this.error = 'Email is missing'
@ -64,16 +65,16 @@ const EditStaff = {
if (this.staff.id) {
promise = Staff.updateStaff(this.staff.id, {
name: this.staff.name,
fullname: this.staff.fullname,
email: this.staff.email,
rank: this.staff.rank,
level: this.staff.level,
password: this.staff.password,
})
} else {
promise = Staff.createStaff({
name: this.staff.name,
fullname: this.staff.fullname,
email: this.staff.email,
rank: this.staff.rank,
level: this.staff.level,
password: this.staff.password,
})
}
@ -91,11 +92,11 @@ const EditStaff = {
},
updateLevel: function(e) {
this.staff.rank = Number(e.currentTarget.value)
this.staff.level = Number(e.currentTarget.value)
},
view: function(vnode) {
const ranks = [[10, 'Manager'], [100, 'Admin']]
const levels = [[10, 'Manager'], [100, 'Admin']]
return (
this.loading ?
m('div.loading-spinner')
@ -107,7 +108,7 @@ const EditStaff = {
]
: null),
m('article.editstaff', [
m('header', m('h1', this.creating ? 'Create Staff' : 'Edit ' + (this.staff.name || '(untitled)'))),
m('header', m('h1', this.creating ? 'Create Staff' : 'Edit ' + (this.staff.fullname || '(untitled)'))),
m('div.error', {
hidden: !this.error,
onclick: function() { vnode.state.error = '' },
@ -118,12 +119,12 @@ const EditStaff = {
m('label', 'Level'),
m('select', {
onchange: this.updateLevel.bind(this),
}, ranks.map(function(rank) { return m('option', { value: rank[0], selected: rank[0] === vnode.state.staff.rank }, rank[1]) })),
}, levels.map(function(level) { return m('option', { value: level[0], selected: level[0] === vnode.state.staff.level }, level[1]) })),
m('label', 'Fullname'),
m('input', {
type: 'text',
value: this.staff.name,
oninput: this.updateValue.bind(this, 'name'),
value: this.staff.fullname,
oninput: this.updateValue.bind(this, 'fullname'),
}),
m('label', 'Email'),
m('input', {
@ -145,7 +146,7 @@ const EditStaff = {
]),
])
)
},*/
},
}
module.exports = EditStaff

46
app/admin/froala.js Normal file
View file

@ -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

113
app/admin/pages.js Normal file
View file

@ -0,0 +1,113 @@
const Page = 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
document.title = 'Pages - Admin NFP Moe'
Page.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
Page.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

66
app/admin/pages.scss Normal file
View file

@ -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: 150px;
}
}
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;
}

49
app/admin/staff.scss Normal file
View file

@ -0,0 +1,49 @@
article.editstaff {
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;
}
}
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;
&.full {
width: 100%;
}
}
}

View file

@ -1,8 +1,8 @@
const Dialogue = require('./dialogue')
const Pages = require('../paginator')
const Staff = require('../api/staff')
const Dialogue = require('../widgets/dialogue')
const Pages = require('../widgets/pages')
const AdminStaffList = {
/*
oninit: function(vnode) {
this.error = ''
this.lastpage = m.route.param('page') || '1'
@ -77,9 +77,9 @@ const AdminStaffList = {
),
m('tbody', this.staff.map(function(item) {
return m('tr', [
m('td', m(m.route.Link, { href: '/admin/staff/' + item.id }, item.name)),
m('td', m(m.route.Link, { href: '/admin/staff/' + item.id }, item.fullname)),
m('td', item.email),
m('td.right', AdminStaffList.getLevel(item.rank)),
m('td.right', AdminStaffList.getLevel(item.level)),
m('td.right', (item.updated_at || '---').replace('T', ' ').split('.')[0]),
m('td.right', m('button', { onclick: function() { vnode.state.removeStaff = item } }, 'Remove')),
])
@ -95,7 +95,7 @@ const AdminStaffList = {
m(Dialogue, {
hidden: vnode.state.removeStaff === null,
title: 'Delete ' + (vnode.state.removeStaff ? vnode.state.removeStaff.name : ''),
message: 'Are you sure you want to remove "' + (vnode.state.removeStaff ? vnode.state.removeStaff.name : '') + '" (' + (vnode.state.removeStaff ? vnode.state.removeStaff.email : '') + ')',
message: 'Are you sure you want to remove "' + (vnode.state.removeStaff ? vnode.state.removeStaff.fullname : '') + '" (' + (vnode.state.removeStaff ? vnode.state.removeStaff.email : '') + ')',
yes: 'Remove',
yesclass: 'alert',
no: 'Cancel',
@ -104,7 +104,7 @@ const AdminStaffList = {
onno: function() { vnode.state.removeStaff = null },
}),
]
},*/
},
}
module.exports = AdminStaffList

64
app/api/article.js Normal file
View file

@ -0,0 +1,64 @@
const common = require('./common')
exports.createArticle = function(body) {
return common.sendRequest({
method: 'POST',
url: '/api/articles',
body: body,
})
}
exports.updateArticle = function(id, body) {
return common.sendRequest({
method: 'PUT',
url: '/api/articles/' + id,
body: body,
})
}
exports.getAllArticles = function() {
return common.sendRequest({
method: 'GET',
url: '/api/articles?includes=parent',
})
}
exports.getAllArticlesPagination = function(options) {
let extra = ''
if (options.sort) {
extra += '&sort=' + options.sort
}
if (options.per_page) {
extra += '&perPage=' + options.per_page
}
if (options.page) {
extra += '&page=' + options.page
}
if (options.includes) {
extra += '&includes=' + options.includes.join(',')
}
return '/api/articles?' + extra
}
exports.getAllPageArticles = function(pageId, includes) {
return common.sendRequest({
method: 'GET',
url: '/api/pages/' + pageId + '/articles?includes=' + includes.join(','),
})
}
exports.getArticle = function(id) {
return common.sendRequest({
method: 'GET',
url: '/api/articles/' + id + '?includes=media,parent,banner,files',
})
}
exports.removeArticle = function(article, id) {
return common.sendRequest({
method: 'DELETE',
url: '/api/articles/' + id,
})
}

46
app/api/article.p.js Normal file
View file

@ -0,0 +1,46 @@
const common = require('./common')
exports.getAllArticlesPagination = function(options) {
let extra = ''
if (options.sort) {
extra += '&sort=' + options.sort
}
if (options.per_page) {
extra += '&perPage=' + options.per_page
}
if (options.page) {
extra += '&page=' + options.page
}
if (options.includes) {
extra += '&includes=' + options.includes.join(',')
}
return '/api/articles/public?' + extra
}
exports.getAllPageArticlesPagination = function(pageId, options) {
let extra = ''
if (options.sort) {
extra += '&sort=' + options.sort
}
if (options.per_page) {
extra += '&perPage=' + options.per_page
}
if (options.page) {
extra += '&page=' + options.page
}
if (options.includes) {
extra += '&includes=' + options.includes.join(',')
}
return '/api/pages/' + pageId + '/articles/public?' + extra
}
exports.getArticle = function(id) {
return common.sendRequest({
method: 'GET',
url: '/api/articles/public/' + id + '?includes=media,parent,banner,files,staff',
})
}

View file

@ -1,4 +1,4 @@
const Authentication = require('./authentication')
const Authentication = require('../authentication')
exports.sendRequest = function(options, isPagination) {
let token = Authentication.getToken()
@ -41,7 +41,7 @@ exports.sendRequest = function(options, isPagination) {
return m.request(options)
.catch(function (error) {
if (error.status === 403) {
if (error.code === 403) {
Authentication.clearToken()
m.route.set('/login', { redirect: m.route.get() })
}

12
app/api/file.js Normal file
View file

@ -0,0 +1,12 @@
const common = require('./common')
exports.uploadFile = function(articleId, file) {
let formData = new FormData()
formData.append('file', file)
return common.sendRequest({
method: 'POST',
url: '/api/articles/' + articleId + '/file',
body: formData,
})
}

17
app/api/media.js Normal file
View file

@ -0,0 +1,17 @@
const common = require('./common')
exports.uploadMedia = function(file, height) {
let formData = new FormData()
formData.append('file', file)
let extra = ''
if (height) {
extra = '?height=' + height
}
return common.sendRequest({
method: 'POST',
url: '/api/media' + extra,
body: formData,
})
}

99
app/api/page.js Normal file
View file

@ -0,0 +1,99 @@
const common = require('./common')
const Tree = window.__nfptree || []
exports.Tree = Tree
exports.createPage = function(body) {
return common.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() {
return common.sendRequest({
method: 'GET',
url: '/api/pages?tree=true&includes=children&fields=id,name,path,children(id,name,path)',
})
}
exports.updatePage = function(id, body) {
return common.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 common.sendRequest({
method: 'GET',
url: '/api/pages',
})
}
exports.getPage = function(id) {
return common.sendRequest({
method: 'GET',
url: '/api/pages/' + id + '?includes=media,banner',
})
}
exports.removePage = function(page, id) {
return common.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
})
}

19
app/api/page.p.js Normal file
View file

@ -0,0 +1,19 @@
const common = require('./common')
const Tree = window.__nfptree || []
exports.Tree = Tree
exports.getTree = function() {
return common.sendRequest({
method: 'GET',
url: '/api/pagetree',
})
}
exports.getPage = function(id) {
return common.sendRequest({
method: 'GET',
url: '/api/pages/' + id + '?includes=media,banner,children,parent',
})
}

16
app/api/pagination.js Normal file
View file

@ -0,0 +1,16 @@
const parse = require('parse-link-header')
const common = require('./common')
exports.fetchPage = function(url) {
return common.sendRequest({
method: 'GET',
url: url,
}, true)
.then(function(result) {
return {
data: result.data,
links: parse(result.headers.link || ''),
total: Number(result.headers.pagination_total || '0'),
}
})
}

38
app/api/staff.js Normal file
View file

@ -0,0 +1,38 @@
const common = require('./common')
exports.createStaff = function(body) {
return common.sendRequest({
method: 'POST',
url: '/api/staff',
body: body,
})
}
exports.updateStaff = function(id, body) {
return common.sendRequest({
method: 'PUT',
url: '/api/staff/' + id,
body: body,
})
}
exports.getAllStaff = function() {
return common.sendRequest({
method: 'GET',
url: '/api/staff',
})
}
exports.getStaff = function(id) {
return common.sendRequest({
method: 'GET',
url: '/api/staff/' + id,
})
}
exports.removeStaff = function(id) {
return common.sendRequest({
method: 'DELETE',
url: '/api/staff/' + id,
})
}

274
app/app.scss Normal file
View file

@ -0,0 +1,274 @@
@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 {
min-height: 100%;
font-family: Arial, Helvetica, sans-serif;
}
button {
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;
}
.maincontainer {
background: $main-bg;
color: $main-fg;
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.container {
width: 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;
}
.error-wrapper {
flex-grow: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: $border;
padding: 40px 0;
.error {
border: 2px dashed $secondary-dark-bg;
padding: 10px 20px;
font-size: 1em;
}
}
[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],
input[type=datetime-local],
select,
textarea {
width: 100%;
font-size: 1em;
padding: 0.5em;
margin: 0 0 0.5em;
background: $input-bg;
border: 1px solid $input-border;
color: $input-fg;
outline: none;
&:hover,
&:focus {
border-color: $secondary-bg;
}
}
input[type=submit],
button.submit {
align-self: center;
padding: 0.5em;
margin: 0.5em 0;
min-width: 150px;
border: 1px solid $secondary-bg;
background: $secondary-light-bg;
color: $secondary-light-fg;
height: 31px;
}
button.submit::-moz-focus-inner {
border: 0;
}
}
a, button {
outline: none;
}
button::-moz-focus-inner,
input[type="button"]::-moz-focus-inner,
input[type="submit"]::-moz-focus-inner,
input[type="reset"]::-moz-focus-inner {
padding: 0 !important;
border: 0 none !important;
}
@import 'froala';
@import 'menu/menu';
@import 'footer/footer';
@import 'login/login';
@import 'widgets/common';
@import 'pages/page';
@import 'article/article';
@import 'frontpage/frontpage';
.darkmodeon {
.maincontainer {
background: $dark_main-bg;
color: $dark_main-fg;
}
.error {
color: $dark_secondary-dark-bg;
}
.fr-view blockquote {
border-left-color: $dark_main-fg;
color: $dark_main-fg;
}
article.article,
article.login,
article.page {
header {
h1 {
// color: $dark_title-fg;
}
span {
color: $dark_meta-fg;
}
}
.content {
h5 {
color: $dark_title-fg;
}
}
}
.login form {
input[type=text],
input[type=password],
select,
textarea {
background: $dark_input-bg;
border: 1px solid $dark_input-border;
color: $dark_input-fg;
&:hover,
&:focus {
border-color: $dark_secondary-bg;
}
}
input[type=submit] {
border: 1px solid $dark_secondary-bg;
background: $dark_secondary-light-bg;
color: $dark_secondary-light-fg;
&:hover {
background: $dark_secondary-dark-bg;
color: $dark_secondary-dark-fg;
}
}
}
.fr-view {
a { color: $dark_secondary-dark-bg; }
}
}

163
app/article/article.js Normal file
View file

@ -0,0 +1,163 @@
const m = require('mithril')
const ApiArticle = require('../api/article.p')
const Authentication = require('../authentication')
const Fileinfo = require('../widgets/fileinfo')
const Article = {
oninit: function(vnode) {
this.error = ''
this.lastarticle = m.route.param('article') || '1'
this.showcomments = false
if (window.__nfpdata) {
this.path = m.route.param('id')
this.article = window.__nfpdata
window.__nfpdata = null
} else {
this.fetchArticle(vnode)
}
},
fetchArticle: function(vnode) {
this.error = ''
this.path = m.route.param('id')
this.showcomments = false
this.article = {
id: 0,
name: '',
path: '',
description: '',
media: null,
banner: null,
files: [],
}
this.loading = true
ApiArticle.getArticle(this.path)
.then(function(result) {
vnode.state.article = result
if (result.parent) {
document.title = result.name + ' - ' + result.parent.name + ' - NFP Moe'
} else {
document.title = result.name + ' - NFP Moe'
}
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
vnode.state.loading = false
m.redraw()
})
},
onbeforeupdate: function(vnode) {
if (this.path !== m.route.param('id')) {
this.fetchArticle(vnode)
}
},
view: function(vnode) {
var deviceWidth = window.innerWidth
var imagePath = ''
if (this.article.media) {
var pixelRatio = window.devicePixelRatio || 1
if ((deviceWidth < 800 && pixelRatio <= 1)
|| (deviceWidth < 600 && pixelRatio > 1)) {
imagePath = this.article.media.medium_url
} else {
imagePath = this.article.media.large_url
}
}
return (
this.loading ?
m('article.article', m('div.loading-spinner'))
: this.error
? m('div.error-wrapper', m('div.error', {
onclick: function() {
vnode.state.error = ''
vnode.state.fetchArticle(vnode)
},
}, 'Article error: ' + this.error))
: m('article.article', [
this.article.parent ? m('div.goback', ['« ', m(m.route.Link, { href: '/page/' + this.article.parent.path }, this.article.parent.name)]) : null,
m('header', m('h1', this.article.name)),
m('.fr-view', [
this.article.media
? m('a.cover', {
rel: 'noopener',
href: this.article.media.link,
}, m('img', { src: imagePath, alt: 'Cover image for ' + this.article.name }))
: null,
this.article.description ? m.trust(this.article.description) : null,
(this.article.files && this.article.files.length
? this.article.files.map(function(file) {
return m(Fileinfo, { file: file })
})
: null),
m('div.entrymeta', [
'Posted ',
(this.article.parent ? 'in' : ''),
(this.article.parent ? m(m.route.Link, { href: '/page/' + this.article.parent.path }, this.article.parent.name) : null),
'at ' + (this.article.published_at.replace('T', ' ').split('.')[0]).substr(0, 16),
' by ' + (this.article.staff && this.article.staff.fullname || 'Admin'),
]),
]),
Authentication.currentUser
? m('div.admin-actions', [
m('span', 'Admin controls:'),
m(m.route.Link, { href: '/admin/articles/' + this.article.id }, 'Edit article'),
])
: null,
this.showcomments
? m('div.commentcontainer', [
m('div#disqus_thread', { oncreate: function() {
let fullhost = window.location.protocol + '//' + window.location.host
/*eslint-disable */
window.disqus_config = function () {
this.page.url = fullhost + '/article/' + vnode.state.article.path
this.page.identifier = 'article-' + vnode.state.article.id
};
(function() { // DON'T EDIT BELOW THIS LINE
var d = document, s = d.createElement('script');
s.src = 'https://nfp-moe.disqus.com/embed.js';
s.setAttribute('data-timestamp', +new Date());
(d.head || d.body).appendChild(s);
})()
/*eslint-enable */
}}, m('div.loading-spinner')),
])
: m('button.opencomments', {
onclick: function() { vnode.state.showcomments = true },
}, 'Open comment discussion'),
])
)
},
}
module.exports = Article
/*
<div id="disqus_thread"></div>
<script>
/**
* RECOMMENDED CONFIGURATION VARIABLES: EDIT AND UNCOMMENT THE SECTION BELOW TO INSERT DYNAMIC VALUES FROM YOUR PLATFORM OR CMS.
* LEARN WHY DEFINING THESE VARIABLES IS IMPORTANT: https://disqus.com/admin/universalcode/#configuration-variables*/
/*
var disqus_config = function () {
this.page.url = PAGE_URL; // Replace PAGE_URL with your page's canonical URL variable
this.page.identifier = PAGE_IDENTIFIER; // Replace PAGE_IDENTIFIER with your page's unique identifier variable
};
/
(function() { // DON'T EDIT BELOW THIS LINE
var d = document, s = d.createElement('script');
s.src = 'https://nfp-moe.disqus.com/embed.js';
s.setAttribute('data-timestamp', +new Date());
(d.head || d.body).appendChild(s);
})();
</script>
<noscript>Please enable JavaScript to view the <a href="https://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>
*/

151
app/article/article.scss Normal file
View file

@ -0,0 +1,151 @@
article.article {
padding: 0;
header {
text-align: center;
margin: 20px 20px 0;
padding: 10px;
background: $secondary-bg;
width: 100%;
max-width: 1920px;
align-self: center;
h1 {
color: $secondary-fg;
}
}
.loading-spinner {
position: relative;
flex-grow: 2;
height: 300px;
}
.cover {
margin: 0 0 20px;
}
.admin-actions {
margin-bottom: 20px;
}
.fr-view {
margin: 0 20px;
padding: 20px 20px 10px;
width: calc(100% - 40px);
max-width: 800px;
flex: 2 0 0;
align-self: center;
background: $newsitem-bg;
border-right: 1px solid #343536;
border-left: 1px solid #343536;
a.cover img {
margin-bottom: 30px;
}
main {
padding: 0 5px;
}
}
fileinfo {
font-size: 0.8em;
ul {
padding-left: 0;
}
}
.opencomments {
border: none;
align-self: center;
width: calc(100% - 40px);
max-width: 300px;
background: transparent;
font-size: 1.2em;
color: $secondary-dark-bg;
cursor: pointer;
height: 50px;
margin: 0 0 30px;
}
.commentcontainer {
align-self: center;
width: calc(100% - 40px);
max-width: 800px;
margin-bottom: 30px;
min-height: 50px;
position: relative;
}
.goback {
width: calc(100% - 40px);
max-width: 800px;
align-self: center;
padding: 30px 5px 0;
margin-bottom: -10px;
a {
font-weight: bold;
text-decoration: none;
color: $secondary-dark-bg;
}
}
.entrymeta {
flex-grow: 2;
font-size: 11px;
color: $meta-fg;
font-weight: bold;
margin: 40px 0 0;
a {
color: $secondary-dark-bg;
margin: 0 4px;
text-decoration: none;
}
}
}
@media screen and (max-width: 639px){
article.article {
.fr-view {
margin: 0;
width: 100%;
border: none !important;
}
}
}
.darkmodeon {
article.article {
header {
background: $dark_secondary-bg;
h1 {
color: $dark_secondary-fg;
}
}
.fr-view {
background: $dark_newsitem-bg;
}
.opencomments {
color: $dark_secondary-dark-bg;
}
.goback a {
color: $dark_secondary-dark-bg;
}
.entrymeta {
color: $dark_meta-fg;
a {
color: $dark_secondary-dark-bg;
}
}
}
}

View file

@ -1,4 +1,4 @@
const storageName = 'nfp_sites_logintoken'
const storageName = 'logintoken'
const Authentication = {
currentUser: null,
@ -37,6 +37,4 @@ const Authentication = {
Authentication.updateToken(localStorage.getItem(storageName))
window.Authentication = Authentication
module.exports = Authentication

25
app/darkmode.js Normal file
View file

@ -0,0 +1,25 @@
const storageName = 'darkmode'
const Darkmode = {
darkIsOn: false,
setDarkMode: function(setOn) {
if (setOn) {
localStorage.setItem(storageName, true)
document.body.className = 'darkmodeon' + ' ' + (window.supportsavif ? 'avifsupport' : 'jpegonly')
Darkmode.darkIsOn = true
} else {
localStorage.removeItem(storageName)
document.body.className = 'daymode' + ' ' + (window.supportsavif ? 'avifsupport' : 'jpegonly')
Darkmode.darkIsOn = false
}
},
isOn: function() {
return Darkmode.darkIsOn
},
}
Darkmode.darkIsOn = localStorage.getItem(storageName)
module.exports = Darkmode

View file

@ -1,7 +1,6 @@
const m = require('mithril')
const PageTree = require('./page_tree')
const Authentication = require('./authentication')
const common = require('./common')
const Page = require('../api/page.p')
const Authentication = require('../authentication')
const Footer = {
oninit: function(vnode) {
@ -10,35 +9,23 @@ const Footer = {
view: function() {
return [
m('div.first'),
m('div.middle', [
m('span', 'Sitemap'),
m('div.footer-filler'),
m('div.sitemap', [
m('div', 'Sitemap'),
m(m.route.Link, { class: 'root', href: '/' }, 'Home'),
PageTree.Tree.map(function(page) {
Page.Tree.map(function(page) {
return [
m(m.route.Link, { class: 'root', href: '/page/' + page.path }, page.name),
(page.children
(page.children.length
? m('ul', page.children.map(function(subpage) {
return m('li', m(m.route.Link, { class: 'child', href: '/page/' + subpage.path }, subpage.name))
}))
: null),
]
}),
m('ul', [
m('li', !Authentication.currentUser
? m(m.route.Link, { class: 'root', href: '/login' }, 'Login')
: null),
m('li', m('button',
{ onclick: common.toggleDarkMode.bind(this) },
common.darkIsOn ? 'Day mode' : 'Night mode'
)),
]),
m('div.meta', [
'Chibi designed and drawn by the amazing and talented ',
m('a', { rel: 'noopener', href: 'https://shoritsu.xyz/', target: '_blank' }, 'SHORITSU'),
'.',
]),
!Authentication.currentUser
? m(m.route.Link, { class: 'root', href: '/login' }, 'Login')
: null,
m('div.meta', [
'©'
+ this.year
@ -47,7 +34,7 @@ const Footer = {
' (Fuck EU)',
]),
]),
m('div.asuna.spritesheet'),
m('div.footer-logo'),
]
},
}

155
app/footer/footer.scss Normal file
View file

@ -0,0 +1,155 @@
footer {
margin-top: 0px;
border-top: 1px solid $border;
display: flex;
padding: 20px;
background: $border;
color: $border-fg;
.sitemap {
display: flex;
flex: 2 1 auto;
flex-direction: column;
text-align: center;
align-items: center;
font-size: 11px;
font-weight: bold;
padding: 0 20px;
a {
text-decoration: none;
color: $border-fg-url;
}
a.root {
display: block;
margin: 2px;
padding: 2px 0 0;
}
ul {
margin: 2px 0 0;
display: flex;
flex-wrap: wrap;
padding: 0 0 10px;
justify-content: center;
border-bottom: 1px solid white;
margin-bottom: 10px;
min-width: 300px;
li {
padding: 2px 5px;
list-style-type: disc;
list-style-position: inside;
}
}
}
.footer-filler {
width: 119px;
flex: 0 0 119px;
}
.footer-logo {
background: center no-repeat transparent;
background-size: contain;
align-self: center;
width: 119px;
height: 150px;
flex: 0 0 119px;
}
.meta {
flex-grow: 2;
display: flex;
flex-wrap: wrap;
padding-top: 5px;
align-items: flex-end;
text-align: center;
justify-content: center;
a { margin: 0 3px; }
}
}
.darkmodeon {
footer {
border-top: 1px solid $dark_border;
background: $dark_border;
color: $dark_border-fg;
.sitemap {
a {
color: $dark_secondary-darker-fg;
}
}
}
}
.daymode.jpegonly footer .footer-logo {
background-image: url("/assets/img/tsun_small.jpg");
}
.darkmodeon.jpegonly footer .footer-logo {
background-image: url("/assets/img/dark_tsun_small.jpg");
}
.avifsupport footer .footer-logo {
background-image: url("/assets/img/tsun.avif");
}
@media
only screen and (-webkit-min-device-pixel-ratio: 2),
only screen and ( min--moz-device-pixel-ratio: 2),
only screen and ( -o-min-device-pixel-ratio: 2/1),
only screen and ( min-device-pixel-ratio: 2),
only screen and ( min-resolution: 192dpi),
only screen and ( min-resolution: 2dppx) {
.daymode.jpegonly .footer-logo {
background-image: url("/assets/img/tsun.jpg");
}
.darkmodeon.jpegonly .footer-logo {
background-image: url("/assets/img/dark_tsun.jpg");
}
}
@media (pointer:coarse) {
footer .sitemap a.root,
footer .sitemap a.child {
padding: 10px 10px;
display: inline-block;
}
}
@media screen and (max-width: 900px){
footer .footer-filler {
display: none;
}
}
@media screen and (max-width: 480px){
footer {
flex-direction: column;
align-items: center;
.footer-logo {
margin-top: 20px;
}
.sitemap {
padding: 0px;
ul {
align-self: stretch;
}
}
}
footer .sitemap a.root,
footer .sitemap a.child {
padding: 9px 10px;
display: inline-block;
}
}

208
app/froala.scss Normal file
View file

@ -0,0 +1,208 @@
.fr-view {
word-wrap: break-word;
.clearfix::after {
clear: both;
display: block;
content: "";
height: 0
}
h1, h2, h3, h4, h5, h6, p, dl, ol, ul {
margin: 0 0 1em !important;
}
blockquote {
border-left: solid 2px $main-fg;
margin-left: 0;
padding-left: 5px;
color: $main-fg;
}
.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
}
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
}
img {
position: relative;
max-width: 100%
}
img.fr-dib {
margin: 5px auto;
display: block;
float: none;
vertical-align: top
}
img.fr-dib.fr-fil {
margin-left: 0;
text-align: left
}
img.fr-dib.fr-fir {
margin-right: 0;
text-align: right
}
img.fr-dii {
display: inline-block;
float: none;
vertical-align: bottom;
margin-left: 5px;
margin-right: 5px;
max-width: calc(100% - (2 * 5px))
}
img.fr-dii.fr-fil {
float: left;
margin: 5px 5px 5px 0;
max-width: calc(100% - 5px)
}
img.fr-dii.fr-fir {
float: right;
margin: 5px 0 5px 5px;
max-width: calc(100% - 5px)
}
span.fr-img-caption {
position: relative;
max-width: 100%
}
span.fr-img-caption.fr-dib {
margin: 5px auto;
display: block;
float: none;
vertical-align: top
}
span.fr-img-caption.fr-dib.fr-fil {
margin-left: 0;
text-align: left
}
span.fr-img-caption.fr-dib.fr-fir {
margin-right: 0;
text-align: right
}
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))
}
span.fr-img-caption.fr-dii.fr-fil {
float: left;
margin: 5px 5px 5px 0;
max-width: calc(100% - 5px)
}
span.fr-img-caption.fr-dii.fr-fir {
float: right;
margin: 5px 0 5px 5px;
max-width: calc(100% - 5px)
}
a.fr-strong {
font-weight: 700
}
a.fr-green {
color: green
}
.fr-img-caption {
text-align: center
}
.fr-img-caption .fr-img-wrap {
padding: 0;
display: inline-block;
margin: auto;
text-align: center;
width: 100%
}
.fr-img-caption .fr-img-wrap img {
display: block;
margin: auto;
width: 100%
}
.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
}
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%; }
strong { font-weight: 700 }
}

156
app/frontpage/frontpage.js Normal file
View file

@ -0,0 +1,156 @@
const m = require('mithril')
const Page = require('../api/page.p')
const Article = require('../api/article.p')
const Pagination = require('../api/pagination')
const Pages = require('../widgets/pages')
const Newsitem = require('../widgets/newsitem')
const Frontpage = {
oninit: function(vnode) {
this.error = ''
this.loading = false
this.featured = null
this.links = null
if (window.__nfpfeatured) {
this.featured = window.__nfpfeatured
}
if (window.__nfpdata
&& window.__nfplinks) {
this.links = window.__nfplinks
this.articles = window.__nfpdata
this.lastpage = m.route.param('page') || '1'
window.__nfpdata = null
window.__nfplinks = null
if (this.articles.length === 0) {
m.route.set('/')
} else {
Frontpage.processFeatured(vnode, this.articles)
}
} else {
this.fetchArticles(vnode)
}
},
onupdate: function(vnode) {
if (this.lastpage !== (m.route.param('page') || '1')) {
this.fetchArticles(vnode)
m.redraw()
}
},
fetchArticles: function(vnode) {
this.error = ''
this.loading = true
this.links = null
this.articles = []
this.lastpage = m.route.param('page') || '1'
if (this.lastpage !== '1') {
document.title = 'Page ' + this.lastpage + ' - NFP Moe - Anime/Manga translation group'
} else {
document.title = 'NFP Moe - Anime/Manga translation group'
}
return Pagination.fetchPage(Article.getAllArticlesPagination({
per_page: 10,
page: this.lastpage,
includes: ['parent', 'files', 'media', 'banner', 'staff'],
}))
.then(function(result) {
vnode.state.articles = result.data
vnode.state.links = result.links
Frontpage.processFeatured(vnode, result.data)
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
vnode.state.loading = false
m.redraw()
})
},
processFeatured: function(vnode, data) {
if (vnode.state.featured) return
for (var i = data.length - 1; i >= 0; i--) {
if (data[i].banner) {
vnode.state.featured = data[i]
}
}
},
view: function(vnode) {
var deviceWidth = window.innerWidth
var bannerPath = ''
if (this.featured && this.featured.banner) {
var pixelRatio = window.devicePixelRatio || 1
if (deviceWidth < 400 && pixelRatio <= 1) {
bannerPath = window.supportsavif
&& this.featured.banner.small_url_avif
|| this.featured.banner.small_url
} else if ((deviceWidth < 800 && pixelRatio <= 1)
|| (deviceWidth < 600 && pixelRatio > 1)) {
bannerPath = window.supportsavif
&& this.featured.banner.medium_url_avif
|| this.featured.banner.medium_url
} else {
bannerPath = window.supportsavif
&& this.featured.banner.large_url_avif
|| this.featured.banner.large_url
}
}
return [
(bannerPath
? m(m.route.Link, {
class: 'frontpage-banner',
href: '/article/' + this.featured.path,
style: { 'background-image': 'url("' + bannerPath + '")' },
},
this.featured.name
)
: null),
m('frontpage', [
m('aside.sidebar', [
m('div.categories', [
m('h4', 'Categories'),
m('div',
Page.Tree.map(function(page) {
return [
m(m.route.Link, { class: 'root', href: '/page/' + page.path }, page.name),
(page.children.length
? m('ul', page.children.map(function(subpage) {
return m('li', m(m.route.Link, { class: 'child', href: '/page/' + subpage.path }, subpage.name))
}))
: null),
]
})
),
]),
m('div.asunaside', {
class: window.supportsavif ? 'avif' : 'jpeg'
}),
]),
m('.frontpage-news', [
(this.loading
? m('div.loading-spinner')
: null),
this.articles.map(function(article) {
return m(Newsitem, article)
}),
m(Pages, {
base: '/',
links: this.links,
}),
]),
]),
]
},
}
module.exports = Frontpage

View file

@ -0,0 +1,197 @@
.frontpage-banner {
background-color: $meta-light-fg;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
height: 150px;
width: calc(100% - 40px);
max-width: 1920px;
align-self: center;
flex: 0 0 150px;
margin-bottom: 20px;
color: white;
text-shadow: 0 0 0.3em #000000;
text-align: right;
font-size: 1.6em;
padding: 10px 20px;
text-decoration: none;
margin: 20px 0;
}
frontpage {
display: flex;
align-self: center;
width: calc(100% - 40px);
max-width: 1200px;
flex: 2 0 0;
.frontpage-news {
display: flex;
flex-direction: column;
flex-grow: 2;
}
aside.sidebar {
width: 250px;
flex: 0 0 250px;
align-self: flex-start;
margin-right: 20px;
display: flex;
flex-direction: column;
.categories {
padding: 10px 10px 20px;
margin-bottom: 20px;
background: $newsitem-bg;
border: $newsitem-border;
}
h4 {
font-size: 14px;
padding: 0 5px 5px;
font-weight: bold;
margin: 0 0 10px;
border-bottom: 1px solid $border;
}
ul {
margin: 0;
padding: 0;
li {
padding-left: 10px;
list-style-type: disc;
list-style-position: inside;
}
}
a {
padding: 2px 5px;
display: block;
text-decoration: none;
color: $secondary-dark-bg;
font-size: 14px;
font-weight: bold;
display: inline-block;
max-width: 200px;
}
}
.loading-spinner {
height: 100px;
position: relative;
}
newsitem {
margin-bottom: 30px;
}
.asunaside {
display: block;
width: 200px;
height: 480px;
background-size: contain;
background-repeat: no-repeat;
background-position: top left;
align-self: center;
}
}
@media screen and (max-width: 1000px) {
frontpage aside.sidebar {
width: 200px;
flex: 0 0 200px;
a {
max-width: 150px;
}
}
}
@media screen and (max-width: 900px){
frontpage {
flex-direction: column;
aside.sidebar {
width: auto;
flex: 0 0 auto;
align-self: stretch;
margin: 20px 0 30px;
border-bottom: 1px solid $border;
order: 2;
.categories {
display: flex;
flex-direction: column;
align-items: center;
h4 {
align-self: stretch;
}
}
a {
max-width: unset;
}
}
}
}
.daymode.jpegonly frontpage .asunaside {
background-image: url("/assets/img/asuna_frontpage.jpg");
}
.daymode.avifsupport frontpage .asunaside {
background-image: url("/assets/img/asuna_frontpage.avif?v=1");
}
.darkmodeon.jpegonly frontpage .asunaside {
background-image: url("/assets/img/dark_asuna_frontpage.jpg");
}
.darkmodeon.avifsupport frontpage .asunaside {
background-image: url("/assets/img/dark_asuna_frontpage.avif?v=1");
}
@media screen and (max-width: 480px){
.frontpage-banner {
width: 100%;
}
frontpage {
padding: 0 10px;
margin: 0;
width: 100%;
}
frontpage aside.sidebar a {
padding: 9px 10px;
}
}
@media (pointer:coarse) {
frontpage aside.sidebar a {
padding: 9px 10px;
}
}
.darkmodeon {
.frontpage-banner {
background-color: $dark_meta-light-fg;
}
frontpage {
aside.sidebar {
.categories {
background: $dark_newsitem-bg;
border: $dark_newsitem-border;
}
h4 {
border-bottom: 1px solid $dark_border;
}
a {
color: $dark_secondary-dark-bg;
}
}
}
}

133
app/index.js Normal file
View file

@ -0,0 +1,133 @@
require('./polyfill')
const m = require('mithril')
window.m = m
m.route.setOrig = m.route.set
m.route.set = function(path, data, options){
m.route.setOrig(path, data, options)
window.scrollTo(0, 0)
}
/*console.log('tree', window.__nfptree)
console.log('featured', window.__nfpfeatured)
console.log('data', window.__nfpdata)
console.log('subdata', window.__nfpsubdata)
console.log('links', window.__nfplinks)*/
m.route.linkOrig = m.route.link
m.route.link = function(vnode){
m.route.linkOrig(vnode)
window.scrollTo(0, 0)
}
const Authentication = require('./authentication')
m.route.prefix = ''
window.adminRoutes = {}
let loadingAdmin = false
let loadedAdmin = false
let loaded = 0
let elements = []
const onLoaded = function() {
loaded++
if (loaded < 2) return
Authentication.setAdmin(Authentication.currentUser && Authentication.currentUser.level >= 10)
loadedAdmin = true
m.route.set(m.route.get())
}
const onError = function() {
elements.forEach(function(x) { x.remove() })
loadedAdmin = loadingAdmin = false
loaded = 0
m.route.set('/logout')
}
const loadAdmin = function(user) {
if (loadingAdmin) {
if (loadedAdmin) {
Authentication.setAdmin(user && user.level >= 10)
}
return
}
if (!user || user.level < 10) return
loadingAdmin = true
let token = Authentication.getToken()
let element = document.createElement('link')
elements.push(element)
element.setAttribute('rel', 'stylesheet')
element.setAttribute('type', 'text/css')
element.setAttribute('href', '/assets/admin.css?token=' + token)
element.onload = onLoaded
element.onerror = onError
document.getElementsByTagName('head')[0].appendChild(element)
element = document.createElement('script')
elements.push(element)
element.setAttribute('type', 'text/javascript')
element.setAttribute('src', '/assets/admin.js?token=' + token)
element.onload = onLoaded
element.onerror = onError
document.body.appendChild(element)
}
Authentication.addEvent(loadAdmin)
if (Authentication.currentUser) {
loadAdmin(Authentication.currentUser)
}
const Menu = require('./menu/menu')
const Footer = require('./footer/footer')
const Frontpage = require('./frontpage/frontpage')
const Login = require('./login/login')
const Logout = require('./login/logout')
const Page = require('./pages/page')
const Article = require('./article/article')
const menuRoot = document.getElementById('nav')
const mainRoot = document.getElementById('main')
const footerRoot = document.getElementById('footer')
const Loader = {
view: function() { return m('div.loading-spinner') },
}
const AdminResolver = {
onmatch: function(args, requestedPath) {
if (window.adminRoutes[args.path]) {
return window.adminRoutes[args.path][args.id && 1 || 0]
}
return Loader
},
render: function(vnode) { return vnode },
}
const allRoutes = {
'/': Frontpage,
'/login': Login,
'/logout': Logout,
'/page/:id': Page,
'/article/:id': Article,
'/admin/:path': AdminResolver,
'/admin/:path/:id': AdminResolver,
}
// Wait until we finish checking avif support, some views render immediately and will ask for this immediately before the callback gets called.
/*
* imgsupport.js from leechy/imgsupport
*/
const AVIF = new Image();
AVIF.onload = AVIF.onerror = function () {
window.supportsavif = (AVIF.height === 2)
document.body.className = document.body.className + ' ' + (window.supportsavif ? 'avifsupport' : 'jpegonly')
m.route(mainRoot, '/', allRoutes)
m.mount(menuRoot, Menu)
m.mount(footerRoot, Footer)
}
AVIF.src = 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A=';

98
app/login/login.js Normal file
View file

@ -0,0 +1,98 @@
const m = require('mithril')
const Authentication = require('../authentication')
const Api = require('../api/common')
const Login = {
loading: false,
redirect: '',
error: '',
oninit: function(vnode) {
Login.redirect = vnode.attrs.redirect || ''
if (Authentication.currentUser) return m.route.set('/')
Login.error = ''
this.username = ''
this.password = ''
},
oncreate: function() {
if (Authentication.currentUser) return
},
loginuser: function(vnode, e) {
e.preventDefault()
if (!this.username) {
Login.error = 'Email is missing'
} else if (!this.password) {
Login.error = 'Password is missing'
} else {
Login.error = ''
}
if (Login.error) return
Login.loading = true
Api.sendRequest({
method: 'POST',
url: '/api/login/user',
body: {
username: this.username,
password: this.password,
},
})
.then(function(result) {
Authentication.updateToken(result.token)
m.route.set(Login.redirect || '/')
})
.catch(function(error) {
Login.error = 'Error while logging into NFP! ' + error.message
vnode.state.password = ''
})
.then(function () {
Login.loading = false
m.redraw()
})
},
view: function(vnode) {
return [
m('div.login-wrapper', [
m('div.login-icon'),
m('article.login', [
m('header', [
m('h1', 'NFP.moe login'),
]),
m('div.content', [
m('h5', 'Please login to access restricted area'),
Login.error ? m('div.error', Login.error) : null,
Login.loading ? m('div.loading-spinner') : null,
m('form', {
hidden: Login.loading,
onsubmit: this.loginuser.bind(this, vnode),
}, [
m('label', 'Email'),
m('input', {
type: 'text',
value: this.username,
oninput: function(e) { vnode.state.username = e.currentTarget.value },
}),
m('label', 'Password'),
m('input', {
type: 'password',
value: this.password,
oninput: function(e) { vnode.state.password = e.currentTarget.value },
}),
m('input', {
type: 'submit',
value: 'Login',
}),
]),
]),
]),
]),
]
},
}
module.exports = Login

60
app/login/login.scss Normal file
View file

@ -0,0 +1,60 @@
@import '../_common';
.login-wrapper {
flex-grow: 2;
display: flex;
flex-direction: column;
justify-content: center;
background: $border;
padding: 40px 0;
.login-icon {
background: url('./img/login.png') center no-repeat transparent;
background-size: contain;
width: 106px;
height: 130px;
align-self: center;
}
}
article.login {
text-align: center;
flex-grow: 0;
border: 1px solid $title-fg;
background: $main-bg;
color: $main-fg;
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;
}
form {
align-self: stretch;
margin-bottom: 20px;
}
}
.darkmodeon {
.login-wrapper {
background: $dark_border;
}
article.login {
border: 1px solid $dark_title-fg;
background: $dark_main-bg;
color: $dark_main-fg;
}
}

15
app/login/logout.js Normal file
View file

@ -0,0 +1,15 @@
const m = require('mithril')
const Authentication = require('../authentication')
const Logout = {
oninit: function() {
Authentication.clearToken()
m.route.set('/')
},
view: function() {
return m('div.loading-spinner')
},
}
module.exports = Logout

95
app/menu/menu.js Normal file
View file

@ -0,0 +1,95 @@
const m = require('mithril')
const Authentication = require('../authentication')
const Darkmode = require('../darkmode')
const Page = require('../api/page.p')
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()
if (Page.Tree.length) return
Menu.loading = true
Page.getTree()
.then(function(results) {
Page.Tree.splice(0, Page.Tree.length)
Page.Tree.push.apply(Page.Tree, results)
})
.catch(function(err) {
Menu.error = err.message
})
.then(function() {
Menu.loading = false
m.redraw()
})
},
view: function() {
return [
m('div.top', [
m(m.route.Link,
{ href: '/', class: 'logo' },
m('h2', 'NFP Moe')
),
m('aside', Authentication.currentUser ? [
m('p', [
'Welcome ' + Authentication.currentUser.email,
m(m.route.Link, { href: '/logout' }, 'Logout'),
(Darkmode.darkIsOn
? m('button.dark', { onclick: Darkmode.setDarkMode.bind(Darkmode, false) }, 'Day mode')
: m('button.dark', { onclick: Darkmode.setDarkMode.bind(Darkmode, true) }, 'Night mode')
),
]),
(Authentication.isAdmin
? m('div.adminlinks', [
m(m.route.Link, { href: '/admin/articles/add' }, 'Create article'),
m(m.route.Link, { href: '/admin/articles' }, 'Articles'),
m(m.route.Link, { href: '/admin/pages' }, 'Pages'),
m(m.route.Link, { hidden: Authentication.currentUser.level < 100, href: '/admin/staff' }, 'Staff'),
])
: (Authentication.currentUser.level > 10 ? m('div.loading-spinner') : null)
),
] : (Darkmode.darkIsOn
? m('button.dark', { onclick: Darkmode.setDarkMode.bind(Darkmode, false) }, 'Day mode')
: m('button.dark', { onclick: Darkmode.setDarkMode.bind(Darkmode, true) }, 'Night mode')
)
),
]),
m('nav', [
m(m.route.Link, {
href: '/',
class: Menu.currentActive === 'home' ? 'active' : '',
}, 'Home'),
Menu.loading ? m('div.loading-spinner') : Page.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

188
app/menu/menu.scss Normal file
View file

@ -0,0 +1,188 @@
@import '../_common';
#nav {
display: flex;
flex-direction: column;
.top {
background: $primary-dark-bg;
color: $primary-dark-fg;
padding: 0 10px 0 0;
min-height: 100px;
display: flex;
button.dark {
background: transparent;
color: $secondary-light-bg;
border: none;
cursor: pointer;
font-weight: bold;
}
a.logo {
background: 25px center no-repeat transparent;
background-size: auto 91px;
padding-left: 120px;
display: flex;
color: $primary-dark-fg;
text-decoration: none;
}
h2 {
align-self: center;
font-size: 30px;
}
aside {
flex-grow: 2;
display: flex;
flex-direction: column;
align-items: flex-end;
padding: 10px 0;
p {
font-size: 0.8em;
color: $meta-light-fg;
padding-bottom: 5px;
a {
margin-left: 5px;
}
button {
font-size: 0.8em;
}
}
a, a:visited {
font-weight: bold;
text-align: center;
color: $secondary-light-bg;
font-size: 0.8em;
line-height: 1.4em;
text-decoration: none;
}
.adminlinks {
display: flex;
justify-content: center;
max-width: 200px;
flex-wrap: wrap;
a {
padding: 3px 5px;
min-width: 100px;
}
}
.loading-spinner {
position: relative;
width: 200px;
}
}
}
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: $primary-fg-url;
font-weight: bold;
}
}
.avifsupport #nav .top a.logo {
background-image: url("/assets/img/logo.avif");
}
.jpegonly #nav .top a.logo {
background-image: url("/assets/img/logo_small.jpg");
}
@media
only screen and (-webkit-min-device-pixel-ratio: 2),
only screen and ( min--moz-device-pixel-ratio: 2),
only screen and ( -o-min-device-pixel-ratio: 2/1),
only screen and ( min-device-pixel-ratio: 2),
only screen and ( min-resolution: 192dpi),
only screen and ( min-resolution: 2dppx) {
.jpegonly #nav .top a.logo {
background-image: url("/assets/img/logo.jpg");
}
}
.darkmodeon {
#nav {
.top {
background: $dark_primary-dark-bg;
color: $dark_primary-dark-fg;
a.logo {
color: $dark_primary-dark-fg;
}
aside {
p {
color: $dark_meta-light-fg;
}
a, a:visited {
color: $dark_secondary-light-bg;
}
}
}
nav {
background: $dark_primary-light-bg;
color: $dark_primary-light-fg;
a, a:visited {
color: $dark_primary-light-fg;
&.active {
border-bottom: 3px solid $dark_secondary-bg;
}
}
}
.menuerror {
background: $dark_primary-bg;
color: $dark_primary-fg-url;
}
}
}

184
app/pages/page.js Normal file
View file

@ -0,0 +1,184 @@
const m = require('mithril')
const ApiPage = require('../api/page.p')
const Article = require('../api/article.p')
const pagination = require('../api/pagination')
const Authentication = require('../authentication')
const Newsentry = require('../widgets/newsentry')
const Pages = require('../widgets/pages')
const Page = {
oninit: function(vnode) {
this.error = ''
this.lastpage = m.route.param('page') || '1'
this.loadingnews = false
console.log(window.__nfpdata)
if (window.__nfpdata) {
this.path = m.route.param('id')
this.page = window.__nfpdata
this.news = window.__nfpsubdata
this.newslinks = window.__nfplinks
window.__nfpdata = null
window.__nfpsubdata = null
} else {
this.fetchPage(vnode)
}
},
fetchPage: function(vnode) {
this.path = m.route.param('id')
this.news = []
this.newslinks = null
this.page = {
id: 0,
name: '',
path: '',
description: '',
media: null,
}
this.loading = true
this.loadingnews = true
ApiPage.getPage(this.path)
.then(function(result) {
vnode.state.page = result
document.title = result.name + ' - NFP Moe'
return vnode.state.fetchArticles(vnode)
})
.catch(function(err) {
vnode.state.error = err.message
vnode.state.loading = vnode.state.loadingnews = false
m.redraw()
})
},
onbeforeupdate: function(vnode) {
if (this.path !== m.route.param('id')) {
this.fetchPage(vnode)
} else if (m.route.param('page') && m.route.param('page') !== this.lastpage) {
this.fetchArticles(vnode)
}
},
fetchArticles: function(vnode) {
this.loadingnews = true
this.newslinks = null
this.lastpage = m.route.param('page') || '1'
return pagination.fetchPage(Article.getAllPageArticlesPagination(this.page.id, {
per_page: 10,
page: this.lastpage,
includes: ['files', 'media'],
}))
.then(function(result) {
vnode.state.news = result.data
vnode.state.newslinks = result.links
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
vnode.state.loading = vnode.state.loadingnews = false
m.redraw()
})
},
view: function(vnode) {
var deviceWidth = window.innerWidth
var pixelRatio = window.devicePixelRatio || 1
var bannerPath = ''
var imagePath = ''
if (this.page && this.page.banner) {
if (deviceWidth < 400 && pixelRatio <= 1) {
bannerPath = this.page.banner.small_url
} else if ((deviceWidth < 800 && pixelRatio <= 1)
|| (deviceWidth < 600 && pixelRatio > 1)) {
bannerPath = this.page.banner.medium_url
} else {
bannerPath = this.page.banner.large_url
}
}
if (this.page && this.page.media) {
if ((deviceWidth < 1000 && pixelRatio <= 1)
|| (deviceWidth < 800 && pixelRatio > 1)) {
imagePath = this.page.media.medium_url
} else {
imagePath = this.page.media.large_url
}
}
return (
this.loading ?
m('article.page', m('div.loading-spinner'))
: this.error
? m('div.error-wrapper', m('div.error', {
onclick: function() {
vnode.state.error = ''
vnode.state.fetchPage(vnode)
},
}, 'Article error: ' + this.error))
: m('article.page', [
bannerPath ? m('.div.page-banner', { style: { 'background-image': 'url("' + bannerPath + '")' } } ) : null,
this.page.parent
? m('div.goback', ['« ', m(m.route.Link, { href: '/page/' + this.page.parent.path }, this.page.parent.name)])
: m('div.goback', ['« ', m(m.route.Link, { href: '/' }, 'Home')]),
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', [
imagePath ? m('a', { href: this.page.media.link}, m('img.page-cover', { src: imagePath, alt: 'Cover image for ' + this.page.name } )) : null,
m.trust(this.page.description),
this.news.length && this.page.description
? m('aside.news', [
m('h4', 'Latest posts under ' + this.page.name + ':'),
this.loadingnews ? m('div.loading-spinner') : this.news.map(function(article) {
return m(Newsentry, article)
}),
m(Pages, {
base: '/page/' + this.page.path,
links: this.newslinks,
}),
])
: null,
])
: this.news.length
? m('aside.news.single', [
imagePath ? m('a', { href: this.page.media.link}, m('img.page-cover', { src: imagePath, alt: 'Cover image for ' + this.page.name } )) : null,
m('h4', 'Latest posts under ' + this.page.name + ':'),
this.loadingnews ? m('div.loading-spinner') : this.news.map(function(article) {
return m(Newsentry, article)
}),
m(Pages, {
base: '/page/' + this.page.path,
links: this.newslinks,
}),
])
: this.page.media
? m('img.page-cover.single', { src: this.page.media.medium_url, alt: 'Cover image for ' + this.page.name } )
: 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

281
app/pages/page.scss Normal file
View file

@ -0,0 +1,281 @@
article.page {
padding-bottom: 0;
header {
text-align: center;
margin: 20px 20px 0;
padding: 10px;
background: $secondary-bg;
width: 100%;
max-width: 1920px;
align-self: center;
h1 {
color: $secondary-fg;
}
}
.loading-spinner {
position: relative;
flex-grow: 2;
height: 300px;
}
.page-banner {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
height: 150px;
width: 100%;
max-width: 1920px;
align-self: center;
flex: 0 0 150px;
}
.page-cover {
margin: 0 0 20px;
&.single {
margin: 0 20px 20px;
padding: 0 20px;
width: calc(100% - 40px);
max-width: 800px;
flex: 2 0 0;
}
}
.goback {
width: 100%;
max-width: 1050px;
align-self: center;
padding: 10px 5px 0;
margin-bottom: -10px;
a {
font-weight: bold;
text-decoration: none;
color: $secondary-dark-bg;
}
}
.admin-actions {
margin-bottom: 20px;
}
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-dark-bg;
// font-size: 14px;
// font-weight: bold;
// }
}
.container {
flex-direction: column;
align-items: center;
height: auto;
padding: 20px 0;
width: 100%;
max-width: 1050px;
align-self: center;
background: $newsitem-bg;
border-right: $newsitem-border;
border-left: $newsitem-border;
&.multi {
align-items: flex-start;
flex-direction: row;
flex-grow: 2;
}
}
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;
text-decoration: none;
color: $secondary-dark-bg;
font-size: 14px;
font-weight: bold;
}
}
.fr-view {
margin: 0 20px;
padding: 0 20px;
width: calc(100% - 40px);
max-width: 800px;
flex: 2 0 0;
.page-cover {
margin: 0 -10px 20px;
}
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;
.loading-spinner {
position: relative;
height: 133px;
}
newsentry {
margin: 0 0 10px;
}
&.single {
// max-width: 800px;
flex: 2 0 0;
padding: 0 20px 10px;
border-top: none;
margin-top: 0;
align-self: flex-start;
margin: 0;
& > h4 {
padding: 0 5px 5px;
border-bottom: 1px solid $border;
}
}
}
@media screen and (max-width: 800px){
article.page aside.sidebar {
width: 200px;
flex: 0 0 200px;
}
}
@media screen and (max-width: 639px){
article.page {
padding: 0;
.container {
flex-direction: column !important;
border: none !important;
}
aside.sidebar {
width: calc(100% - 80px);
flex: 0 0 auto;
margin: 0px 30px 30px;
border-bottom: 1px solid $border;
padding: 0 0 5px;
}
.news.single .page-cover {
margin: 0 0 20px;
}
}
}
@media screen and (max-width: 360px){
article.page {
.container {
flex-direction: column;
}
aside {
margin: 0px 0px 10px;
}
.fr-view {
margin: 0;
width: 100%;
padding: 0 5px;
}
}
}
@media screen and (max-width: 480px){
article.page aside.sidebar a {
padding: 9px 10px;
}
}
@media (pointer:coarse) {
article.page aside.sidebar a {
padding: 9px 10px;
}
}
.darkmodeon {
article.page {
header {
background: $dark_secondary-bg;
h1 {
color: $dark_secondary-fg;
}
}
.container {
background: $dark_newsitem-bg;
border-right: $dark_newsitem-border;
border-left: $dark_newsitem-border;
}
aside.sidebar {
h4 {
border-bottom: 1px solid $dark_border;
}
a {
color: $dark_secondary-dark-bg;
}
}
.goback a {
color: $dark_secondary-dark-bg;
}
}
aside.news {
&.single {
& > h4 {
border-bottom: 1px solid $dark_border;
}
}
}
@media screen and (max-width: 639px){
article.page aside.sidebar {
border-bottom: 1px solid $dark_border;
}
}
.goback a {
color: $dark_secondary-dark-bg;
}
}

8
app/polyfill.js Normal file
View file

@ -0,0 +1,8 @@
if (!String.prototype.endsWith) {
String.prototype.endsWith = function(search, this_len) {
if (this_len === undefined || this_len > this.length) {
this_len = this.length;
}
return this.substring(this_len - search.length, this_len) === search;
};
}

133
app/widgets/admin.scss Normal file
View file

@ -0,0 +1,133 @@
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('./img/upload.svg');
background-position: center;
background-repeat: no-repeat;
background-size: 50px;
}
.showicon {
position: absolute;
top: 5px;
right: 5px;
width: 50px;
height: 50px;
background-image: url('./img/upload.svg');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
img {
max-width: 600px;
width: 100%;
align-self: center;
min-height: 100px;
}
.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;
}
.remove {
border: none;
position: absolute;
top: 5px;
right: 60px;
width: 50px;
height: 50px;
background-image: url('./img/delete.svg');
background-position: center;
background-repeat: no-repeat;
background-color: transparent;
background-size: contain;
z-index: 3;
outline: none;
cursor: pointer;
}
}
dialogue {
background: white;
display: flex;
flex-direction: column;
text-align: center;
width: calc(100% - 40px);
max-width: 500px;
color: $main-fg;
h2 {
background: $secondary-dark-bg;
color: $secondary-dark-fg;
font-size: 1.5em;
padding: 10px;
}
p {
padding: 10px;
}
.buttons {
display: flex;
justify-content: space-around;
padding: 10px;
}
button {
border: 1px solid $secondary-dark-bg;
background: transparent;
color: $secondary-dark-bg;
padding: 5px 15px;
min-width: 150px;
}
button.alert {
border-color: red;
color: red;
}
button.cancel {
border-color: #999;
color: #999;
}
}

321
app/widgets/common.scss Normal file
View file

@ -0,0 +1,321 @@
newsentry {
display: flex;
color: $meta-fg;
font-size: 12px;
.title {
display: flex;
margin-bottom: 10px !important;
a {
text-decoration: none;
color: $secondary-dark-bg;
font-size: 14px;
font-weight: bold;
}
}
a.cover {
flex-shrink: 0;
margin-right: 10px;
width: 124px;
text-align: center;
img {
max-height: 70px;
width: auto;
}
}
a.nobg {
height: 70px;
width: 124px;
background: #ddd;
}
.entrycontent {
display: flex;
flex-direction: column;
flex: 2 1 auto;
padding: 0 5px 5px;
h3 {
margin-bottom: 0 !important;
font-size: 1.3em;
font-weight: bold;
}
}
.entrymeta {
font-size: 10px;
color: $meta-fg;
font-weight: bold;
display: none;
}
}
fileinfo {
padding: 0 5px;
margin-bottom: 5px;
display: flex;
flex-direction: column;
&.slim {
padding: 0;
margin: 0;
}
.filetitle {
display: block;
line-height: 16px;
.prefix {
font-weight: bold;
margin-right: 5px;
}
a {
color: $secondary-dark-bg;
font-weight: bold;
text-decoration: none;
padding-right: 5px;
border-right: 1px solid $border;
margin-right: 5px;
display: inline-block;
}
}
.trimmed {
padding: 3px 0 5px 25px;
}
ul {
margin: 10px 0;
padding-left: 0;
list-style-type: disc;
list-style-position: inside;
li {
padding-left: 20px;
}
}
}
@media screen and (max-width: 480px){
fileinfo,
fileinfo.slim {
padding: 8px 5px;
justify-content: center;
}
}
@media (pointer:coarse) {
fileinfo,
fileinfo.slim {
padding: 8px 5px;
justify-content: center;
}
}
newsitem {
display: flex;
flex-direction: column;
font-size: 15px;
background: $newsitem-bg;
border: $newsitem-border;
.title {
text-decoration: none;
background: $secondary-bg;
color: $secondary-fg;
font-size: 1.2em;
font-weight: bold;
text-align: center;
padding: 5px 10px;
margin-bottom: 10px;
}
.newsitemcontent {
display: flex;
}
a.cover {
flex-shrink: 0;
margin-right: 10px;
width: 400px;
text-align: center;
picture {
max-height: 400px;
max-width: 400px;
width: auto;
}
}
a.nobg {
height: 225px;
width: 400px;
background: #ddd;
}
.entrycontent {
display: flex;
flex-direction: column;
flex: 2 1 auto;
padding: 0 5px 5px;
&.extrapadding {
padding: 0 15px 5px;
}
h3 {
margin-bottom: 0 !important;
font-size: 1.3em;
font-weight: bold;
}
.fr-view {
margin-bottom: 10px;
}
fileinfo {
font-size: 0.8em;
}
}
.entrymeta {
flex-grow: 2;
font-size: 11px;
color: $meta-fg;
font-weight: bold;
padding: 10px 0;
a {
color: $secondary-dark-bg;
margin: 0 5px;
text-decoration: none;
}
}
}
pages {
display: flex;
justify-content: center;
width: 100%;
a, div {
display: block;
font-size: 0.9em;
max-width: 80px;
flex-grow: 2;
text-align: center;
padding: 10px !important;
margin-top: 10px;
}
a {
color: $secondary-dark-bg;
cursor: pointer;
}
}
@media screen and (max-width: 1000px){
newsitem a.cover {
width: 300px;
picture {
max-width: 300px;
width: auto;
}
}
}
@media screen and (max-width: 639px){
newsitem {
a.cover {
width: 100%;
margin-bottom: 20px;
picture {
max-height: unset;
max-width: unset;
width: 100%;
}
}
.newsitemcontent {
flex-direction: column;
}
.entrycontent.extrapadding {
padding: 0 5px 5px;
}
}
}
@media screen and (max-width: 440px){
newsentry {
flex-direction: column;
align-items: center;
a.cover {
margin: 0 0 15px;
width: 100%;
img {
max-height: unset;
width: 100%;
}
}
}
}
.darkmodeon {
newsentry {
color: $dark_meta-fg;
.title {
a {
color: $dark_secondary-dark-bg;
}
}
.entrymeta {
color: $dark_meta-fg;
}
}
fileinfo {
.filetitle {
a {
color: $dark_secondary-dark-bg;
border-right: 1px solid $dark_border;
}
}
}
newsitem {
background: $dark_newsitem-bg;
border: $dark_newsitem-border;
.title {
background: $dark_secondary-bg;
color: $dark_secondary-fg;
}
.entrymeta {
color: $dark_meta-fg;
a {
color: $dark_secondary-dark-bg;
}
}
}
pages {
a {
color: $dark_secondary-dark-bg;
}
}
}

17
app/widgets/dialogue.js Normal file
View file

@ -0,0 +1,17 @@
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

View file

@ -55,19 +55,19 @@ const Fileinfo = {
view: function(vnode) {
return m('fileinfo', { class: vnode.attrs.slim ? 'slim' : ''}, [
m('p', [
m('span', this.getPrefix(vnode) + ':'),
m('div.filetitle', [
m('span.prefix', this.getPrefix(vnode) + ':'),
m('a', {
target: '_blank',
rel: 'noopener',
href: vnode.attrs.file.path,
href: vnode.attrs.file.url,
}, this.getDownloadName(vnode)),
vnode.attrs.file.magnet
? m('a', {
href: vnode.attrs.file.magnet,
}, 'Magnet')
: null,
this.getTitle(vnode),
m('span', this.getTitle(vnode)),
]),
vnode.attrs.file.meta.torrent
&& !vnode.attrs.slim
@ -85,7 +85,6 @@ const Fileinfo = {
&& vnode.attrs.file.meta.torrent.files.length > 4
? m('div.trimmed', '...' + vnode.attrs.file.meta.torrent.files.length + ' files...')
: null,
vnode.children,
])
},
}

69
app/widgets/fileupload.js Normal file
View file

@ -0,0 +1,69 @@
const Media = require('../api/media')
const FileUpload = {
uploadFile: function(vnode, event) {
if (!event.target.files[0]) return
vnode.state.updateError(vnode, '')
vnode.state.loading = true
Media.uploadMedia(event.target.files[0], vnode.attrs.height || null)
.then(function(res) {
if (vnode.attrs.onupload) {
vnode.attrs.onupload(res)
}
})
.catch(function(err) {
vnode.state.updateError(vnode, err.message)
})
.then(function() {
event.target.value = null
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.large_url + '")',
},
}, m('div.showicon'))
: m('div.inside.showbordericon')
),
m('input', {
accept: 'image/*',
type: 'file',
onchange: this.uploadFile.bind(this, vnode),
}),
(media && vnode.attrs.ondelete ? m('button.remove', { onclick: vnode.attrs.ondelete }) : null),
(vnode.state.loading ? m('div.loading-spinner') : null),
])
},
}
module.exports = FileUpload

57
app/widgets/newsentry.js Normal file
View file

@ -0,0 +1,57 @@
const Fileinfo = require('./fileinfo')
const Newsentry = {
strip: function(html) {
var doc = new DOMParser().parseFromString(html, 'text/html')
var out = doc.body.textContent || ''
var splitted = out.split('.')
if (splitted.length > 2) {
return splitted.slice(0, 2).join('.') + '...'
}
return out
},
view: function(vnode) {
var deviceWidth = window.innerWidth
var pixelRatio = window.devicePixelRatio || 1
var imagePath = ''
if (vnode.attrs.media) {
if (deviceWidth > 440 || pixelRatio <= 1) {
imagePath = vnode.attrs.media.small_url
} else {
imagePath = vnode.attrs.media.medium_url
}
}
return m('newsentry', [
imagePath
? m(m.route.Link, {
class: 'cover',
href: '/article/' + vnode.attrs.path,
}, m('picture', [
m('source', { srcset:
vnode.attrs.media.small_url + ''
}),
m('img', { src: imagePath, alt: 'Article image for ' + vnode.attrs.name }),
]))
: m('a.cover.nobg'),
m('div.entrycontent', [
m('div.title', [
m(m.route.Link,
{ href: '/article/' + vnode.attrs.path },
m('h3', [vnode.attrs.name])
),
]),
(vnode.attrs.files && vnode.attrs.files.length
? vnode.attrs.files.map(function(file) {
return m(Fileinfo, { file: file, slim: true })
})
: vnode.attrs.description
? m('span.entrydescription', Newsentry.strip(vnode.attrs.description))
: null),
]),
])
},
}
module.exports = Newsentry

70
app/widgets/newsitem.js Normal file
View file

@ -0,0 +1,70 @@
const Fileinfo = require('./fileinfo')
const Newsitem = {
oninit: function(vnode) {
if (vnode.attrs.media) {
this.srcsetJpeg = vnode.attrs.media.small_url + ' 500w, '
+ vnode.attrs.media.medium_url + ' 800w '
if (vnode.attrs.media.small_url_avif) {
this.srcsetAvif = vnode.attrs.media.small_url_avif + ' 500w, '
+ vnode.attrs.media.medium_url_avif + ' 800w '
} else {
this.srcsetAvif = null
}
this.coverSizes = '(max-width: 639px) calc(100vw - 40px), '
+ '(max-width: 1000px) 300px, '
+ '400px'
}
},
view: function(vnode) {
return m('newsitem', [
m(m.route.Link,
{ href: '/article/' + vnode.attrs.path, class: 'title' },
m('h3', [vnode.attrs.name])
),
m('div.newsitemcontent', [
vnode.attrs.media
? m(m.route.Link, {
class: 'cover',
href: '/article/' + vnode.attrs.path,
},
m('picture', [
this.srcsetAvif ? m('source', {
srcset: this.srcsetAvif,
sizes: this.coverSizes,
type: 'image/avif',
}) : null,
m('img', {
srcset: this.srcsetJpeg,
sizes: this.coverSizes,
alt: 'Image for news item ' + vnode.attrs.name,
src: vnode.attrs.media.small_url }),
])
)
: null,
m('div.entrycontent', {
class: vnode.attrs.media ? '' : 'extrapadding',
}, [
(vnode.attrs.description
? m('.fr-view', m.trust(vnode.attrs.description))
: null),
(vnode.attrs.files && vnode.attrs.files.length
? vnode.attrs.files.map(function(file) {
return m(Fileinfo, { file: file, trim: true })
})
: null),
m('span.entrymeta', [
'Posted ',
(vnode.attrs.parent ? 'in' : ''),
(vnode.attrs.parent ? m(m.route.Link, { href: '/page/' + vnode.attrs.parent.path }, vnode.attrs.parent.name) : null),
'at ' + (vnode.attrs.published_at.replace('T', ' ').split('.')[0]).substr(0, 16),
' by ' + (vnode.attrs.staff && vnode.attrs.staff.fullname || 'Admin'),
]),
]),
]),
])
},
}
module.exports = Newsitem

38
app/widgets/pages.js Normal file
View file

@ -0,0 +1,38 @@
const Pages = {
oninit: function(vnode) {
this.onpage = vnode.attrs.onpage || function() {}
},
view: function(vnode) {
if (!vnode.attrs.links) return null
return m('pages', [
vnode.attrs.links.first
? m(m.route.Link, {
href: vnode.attrs.base + '?page=' + vnode.attrs.links.first.page,
onclick: function() { vnode.state.onpage(vnode.attrs.links.first.page) },
}, 'First')
: m('div'),
vnode.attrs.links.previous
? m(m.route.Link, {
href: vnode.attrs.base + '?page=' + vnode.attrs.links.previous.page,
onclick: function() { vnode.state.onpage(vnode.attrs.links.previous.page) },
}, vnode.attrs.links.previous.title)
: m('div'),
m('div', vnode.attrs.links.current && vnode.attrs.links.current.title || 'Current page'),
vnode.attrs.links.next
? m(m.route.Link, {
href: vnode.attrs.base + '?page=' + vnode.attrs.links.next.page,
onclick: function() { vnode.state.onpage(vnode.attrs.links.next.page) },
}, vnode.attrs.links.next.title)
: m('div'),
vnode.attrs.links.last
? m(m.route.Link, {
href: vnode.attrs.base + '?page=' + vnode.attrs.links.last.page,
onclick: function() { vnode.state.onpage(vnode.attrs.links.last.page) },
}, 'Last')
: m('div'),
])
},
}
module.exports = Pages

View file

@ -1 +0,0 @@
package-lock=false

View file

@ -1,149 +0,0 @@
import { parseArticles, parseArticle } from './util.mjs'
import { uploadMedia, uploadFile, deleteFile } from '../media/upload.mjs'
import { mediaToDatabase } from '../media/util.mjs'
export default class ArticleRoutes {
constructor(opts = {}) {
Object.assign(this, {
uploadMedia: uploadMedia,
uploadFile: uploadFile,
deleteFile: deleteFile,
requireAuth: opts.requireAuth,
})
}
register(server) {
if (!this.requireAuth) {
server.flaska.get('/api/articles/:path', this.getArticle.bind(this))
}
server.flaska.get('/api/auth/articles', server.authenticate(), this.auth_getAllArticles.bind(this))
server.flaska.get('/api/auth/articles/:id', server.authenticate(), this.auth_getSingleArticle.bind(this))
server.flaska.put('/api/auth/articles/:id', [
server.authenticate(),
server.formidable({ maxFileSize: 20 * 1024 * 1024, }),
], this.auth_updateCreateSingleArticle.bind(this))
server.flaska.delete('/api/auth/articles/:id', server.authenticate(), this.auth_removeSingleArticle.bind(this))
}
/** GET: /api/articles/[path] */
async getArticle(ctx, onlyReturn = false) {
let res = await ctx.db.safeCallProc('article_get_single', [ctx.params.path])
if (onlyReturn) {
return this.getArticle_resOutput(res)
}
ctx.body = this.getArticle_resOutput(res)
}
getArticle_resOutput(res) {
return {
article: parseArticle(res.results[0][0]),
}
}
/** GET: /api/auth/articles */
async auth_getAllArticles(ctx) {
let res = await ctx.db.safeCallProc('article_auth_get_all', [
ctx.state.auth_token,
Math.max(ctx.query.get('page') || 1, 1),
Math.min(ctx.query.get('per_page') || 20, 100)
])
let out = {
articles: parseArticles(res.results[0]),
total_articles: res.results[1][0].total_articles,
}
ctx.body = out
}
async private_getUpdateArticle(ctx, body = null, banner = null, media = null) {
let params = [
ctx.state.auth_token,
ctx.params.id === '0' ? null : ctx.params.id
]
if (body) {
params = params.concat([
body.name,
body.page_id === 'null' ? null : Number(body.page_id),
body.path,
body.content,
new Date(body.publish_at),
Number(body.admin_id),
body.is_featured === 'true' ? 1 : 0,
0,
])
params = params.concat(mediaToDatabase(banner, body.remove_banner === 'true'))
params = params.concat(mediaToDatabase(media, body.remove_media === 'true'))
}
let res = await ctx.db.safeCallProc('article_auth_get_update_create', params)
ctx.body = this.private_getUpdateArticle_resOutput(res)
}
private_getUpdateArticle_resOutput(res) {
return {
article: parseArticle(res.results[0][0] || {}),
staff: res.results[1],
}
}
/** GET: /api/auth/articles/:id */
auth_getSingleArticle(ctx) {
return this.private_getUpdateArticle(ctx)
}
/** PUT: /api/auth/articles/:id */
async auth_updateCreateSingleArticle(ctx) {
let newBanner = null
let newMedia = null
let promises = []
if (ctx.req.files.banner) {
promises.push(
this.uploadMedia(ctx.req.files.banner)
.then(res => { newBanner = res })
)
}
if (ctx.req.files.media) {
promises.push(
this.uploadMedia(ctx.req.files.media)
.then(res => { newMedia = res })
)
}
if (ctx.req.body.media && ctx.req.body.media.filename && ctx.req.body.media.type && ctx.req.body.media.path && ctx.req.body.media.size) {
newMedia = ctx.req.body.media
}
await Promise.all(promises)
return this.private_getUpdateArticle(ctx, ctx.req.body, newBanner, newMedia)
}
/** DELETE: /api/auth/articles/:id */
async auth_removeSingleArticle(ctx) {
let params = [
ctx.state.auth_token,
ctx.params.id,
// Article data
null,
null,
null,
null,
null,
null,
null,
1,
]
params = params.concat(mediaToDatabase(null, true))
params = params.concat(mediaToDatabase(null, true))
await ctx.db.safeCallProc('article_auth_get_update_create', params)
ctx.status = 204
}
}

View file

@ -1,17 +0,0 @@
import { contentToBlocks, parseMediaAndBanner } from '../util.mjs'
export function parseArticles(articles) {
for (let i = 0; i < articles.length; i++) {
parseArticle(articles[i])
}
return articles
}
export function parseArticle(article) {
if (!article) {
return null
}
article.content = contentToBlocks(article.content)
parseMediaAndBanner(article)
return article
}

View file

@ -1,34 +0,0 @@
import crypto from 'crypto'
import * as util from '../util.mjs'
import config from '../config.mjs'
export default class AuthenticationRoutes {
constructor(opts = {}) {
Object.assign(this, {
secret: config.get('jwtsecret'),
crypto: crypto,
util: util,
})
}
register(server) {
server.flaska.post('/api/authentication/login', server.jsonHandler(), this.login.bind(this))
}
/** GET: /api/authentication/login */
async login(ctx) {
let res = await ctx.db.safeCallProc('auth_login', [
ctx.req.body.email || '',
ctx.req.body.password || '',
])
let out = res.results[0][0]
const hmac = this.crypto.createHmac('sha256', this.secret)
hmac.update(out.token)
let apiSignature = this.util.encode(hmac.digest())
out.token = out.token + '.' + apiSignature
ctx.body = out
}
}

View file

@ -1,70 +0,0 @@
import crypto from 'crypto'
import { HttpError } from 'flaska'
import { decode, encode } from '../util.mjs'
import config from '../config.mjs'
export const RankLevels = {
Normal: 1,
Manager: 10,
Admin: 100,
}
const issuer = config.get('mssql:connectionUser')
const secret = config.get('jwtsecret')
export function verifyValidToken(parts, minLevel) {
if (parts.length !== 4) {
throw new HttpError(401, 'Authentication token invalid')
}
const hmac = crypto.createHmac('sha256', secret)
hmac.update([parts[0], parts[1], parts[2]].join('.'))
let apiSignature = encode(hmac.digest())
if (apiSignature !== parts[3]) {
throw new HttpError(401, 'Authentication token invalid signature')
}
let header
let body
try {
header = JSON.parse(decode(parts[0]).toString('utf8'))
body = JSON.parse(decode(parts[1]).toString('utf8'))
} catch (err) {
throw new HttpError(401, 'Authentication token invalid json')
}
if (header.alg !== 'HS256') {
throw new HttpError(401, 'Authentication token invalid alg')
}
let unixNow = Math.floor(Date.now() / 1000)
// Validate token, add a little skew support for issued_at
if (body.iss !== issuer || !body.iat || !body.exp
|| body.iat > unixNow + 300 || body.exp <= unixNow) {
throw new HttpError(403, 'Authentication token expired or invalid')
}
if (body.rank < minLevel) {
throw new HttpError(401, 'User does not have access to this resource')
}
return body
}
export function authenticate(minLevel = RankLevels.Manager) {
return function(ctx) {
if (!ctx.req.headers.authorization) {
throw new HttpError(401, 'Authentication token missing')
}
if (!ctx.req.headers.authorization.startsWith('Bearer ')) {
throw new HttpError(401, 'Authentication token invalid')
}
let parts = ctx.req.headers.authorization.slice(7).split('.')
ctx.state.auth_user = verifyValidToken(parts, minLevel)
ctx.state.auth_token = [parts[0], parts[1], parts[2]].join('.')
}
}

Some files were not shown because too many files have changed in this diff Show more