Remove google, add avif support

This commit is contained in:
Jonatan Nilsson 2021-01-04 17:47:59 +00:00
parent 85d31ded75
commit 44bcbe2647
21 changed files with 178 additions and 194 deletions

View file

@ -27,7 +27,7 @@ jobs:
name: Push to docker name: Push to docker
command: | command: |
docker login -u $DOCKER_USER -p $DOCKER_PASS docker login -u $DOCKER_USER -p $DOCKER_PASS
docker push ${di} docker push ${di} --all-tags
- deploy: - deploy:
name: Deploy to production name: Deploy to production
command: | command: |

View file

@ -1,12 +0,0 @@
import googleauth from 'google-auth-library'
import config from '../config.mjs'
const oauth2Client = new googleauth.OAuth2Client(config.get('googleid'))
// This is hard to have always running as it requires a
// test access token which always expire.
/* istanbul ignore next */
export function getProfile(token) {
return oauth2Client.getTokenInfo(token)
}

View file

@ -1,6 +1,5 @@
import Staff from '../staff/model.mjs' import Staff from '../staff/model.mjs'
import Jwt from '../jwt.mjs' import Jwt from '../jwt.mjs'
import * as google from './google.mjs'
import * as security from './security.mjs' import * as security from './security.mjs'
import AuthHelper from './helper.mjs' import AuthHelper from './helper.mjs'
@ -10,37 +9,10 @@ export default class AuthRoutes {
helper: opts.helper || new AuthHelper(), helper: opts.helper || new AuthHelper(),
Staff: opts.Staff || Staff, Staff: opts.Staff || Staff,
jwt: opts.jwt || new Jwt(), jwt: opts.jwt || new Jwt(),
google: opts.google || google,
security: opts.security || security, security: opts.security || security,
}) })
} }
/*
* POST /api/login - Authenticate a user using social login
*
* @body {string} token - The google token to authenticate
* @returns
*
* { token: 'Authentication token' }
*/
async login(ctx) {
let output = await google.getProfile(ctx.request.body.token)
if (output.email_verified !== 'true') ctx.throw(422, 'Email was not verified with google')
if (!output.email) ctx.throw(422, 'Email was missing from google response')
let level = 1
let staff = await this.Staff
.query({ where: { email: output.email }})
.fetch({ require: false })
if (staff && staff.id && staff.get('level')) {
level = staff.get('level')
}
ctx.body = { token: this.jwt.createToken(staff.id, output.email, level) }
}
/* /*
* POST /api/login/user - Authenticate a user using password login * POST /api/login/user - Authenticate a user using password login
* *

View file

@ -1,5 +1,5 @@
import _ from 'lodash' import _ from 'lodash'
import knex from 'knex' import knex from 'knex-core'
import bookshelf from 'bookshelf' import bookshelf from 'bookshelf'
import config from './config.mjs' import config from './config.mjs'
@ -178,7 +178,7 @@ function getConfig(index = 0, addEvents = true) {
afterCreate: addEvents && afterCreate || null, afterCreate: addEvents && afterCreate || null,
min: 2, min: 2,
max: 10, max: 10,
beforeDestroy: addEvents && beforeDestroy || null, // beforeDestroy: addEvents && beforeDestroy || null,
}, },
acquireConnectionTimeout: 10000, acquireConnectionTimeout: 10000,
} }

View file

@ -1,5 +1,5 @@
import _ from 'lodash' import _ from 'lodash'
import nconf from 'nconf' import nconf from 'nconf-lite'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
// Helper method for global usage. // Helper method for global usage.

View file

@ -39,6 +39,21 @@ const Media = bookshelf.createModel({
return `${Media.baseUrl}${this.get('large_image')}` 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() { link() {
return `${Media.baseUrl}${this.get('org_image')}` return `${Media.baseUrl}${this.get('org_image')}`
}, },
@ -54,7 +69,7 @@ const Media = bookshelf.createModel({
}, { }, {
baseUrl: config.get('upload:baseurl'), baseUrl: config.get('upload:baseurl'),
getSubUrl(input, size) { getSubUrl(input, size, type = 'jpg') {
if (!input) return input if (!input) return input
let output = input let output = input
@ -62,7 +77,7 @@ const Media = bookshelf.createModel({
let ext = path.extname(input).toLowerCase() let ext = path.extname(input).toLowerCase()
output = input.slice(0, -ext.length) output = input.slice(0, -ext.length)
} }
return `${output}.${size}.jpg` return `${output}.${size}.${type}`
}, },
}) })

View file

@ -9,49 +9,85 @@ export default class Resizer {
}) })
} }
createSmall(input) { createSmall(input, format = 'jpg') {
let output = this.Media.getSubUrl(input, 'small') let output = this.Media.getSubUrl(input, 'small', format === 'avif' ? 'avif' : 'jpg')
return this.sharp(input) let sized = this.sharp(input)
.resize(360, 360, { .resize(500, 500, {
fit: sharp.fit.inside, fit: sharp.fit.inside,
withoutEnlargement: true, withoutEnlargement: true,
}) })
.jpeg({
quality: 90, if (format === 'avif') {
}) return sized
.toFile(output) .avif({
.then(() => output) quality: 80,
speed: 5,
})
.toFile(output)
.then(() => output)
} else {
return sized
.jpeg({
quality: 93,
})
.toFile(output)
.then(() => output)
}
} }
createMedium(input, height) { createMedium(input, height, format = 'jpg') {
let output = this.Media.getSubUrl(input, 'medium') let output = this.Media.getSubUrl(input, 'medium', format === 'avif' ? 'avif' : 'jpg')
return this.sharp(input) let sized = this.sharp(input)
.resize(700, height || 700, { .resize(800, height || 800, {
fit: height && sharp.fit.cover || sharp.fit.inside, fit: height && sharp.fit.cover || sharp.fit.inside,
withoutEnlargement: true, withoutEnlargement: true,
}) })
.jpeg({
quality: 90, if (format === 'avif') {
}) return sized
.toFile(output) .avif({
.then(() => output) quality: 80,
speed: 5,
})
.toFile(output)
.then(() => output)
} else {
return sized
.jpeg({
quality: 93,
})
.toFile(output)
.then(() => output)
}
} }
createLarge(input) { createLarge(input, format = 'jpg') {
let output = this.Media.getSubUrl(input, 'large') let output = this.Media.getSubUrl(input, 'large', format === 'avif' ? 'avif' : 'jpg')
return this.sharp(input) let sized = this.sharp(input)
.resize(1280, 1280, { .resize(1280, 1280, {
fit: sharp.fit.inside, fit: sharp.fit.inside,
withoutEnlargement: true, withoutEnlargement: true,
}) })
.jpeg({
quality: 90, if (format === 'avif') {
}) return sized
.toFile(output) .avif({
.then(() => output) quality: 85,
speed: 5,
})
.toFile(output)
.then(() => output)
} else {
return sized
.jpeg({
quality: 93,
})
.toFile(output)
.then(() => output)
}
} }
autoRotate(input) { autoRotate(input) {

View file

@ -27,14 +27,20 @@ export default class MediaRoutes {
let smallPath = await this.resize.createSmall(result.path) let smallPath = await this.resize.createSmall(result.path)
let mediumPath = await this.resize.createMedium(result.path, height) let mediumPath = await this.resize.createMedium(result.path, height)
let largePath = await this.resize.createLarge(result.path) 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 token = this.jwt.signDirect({ site: config.get('upload:name') }, config.get('upload:secret'))
let [org, small, medium, large] = await Promise.all([ let [org, small, medium, large, smallAvif, mediumAvif, largeAvif] = await Promise.all([
this.uploadFile(token, result.path), this.uploadFile(token, result.path),
this.uploadFile(token, smallPath), this.uploadFile(token, smallPath),
this.uploadFile(token, mediumPath), this.uploadFile(token, mediumPath),
this.uploadFile(token, largePath), this.uploadFile(token, largePath),
this.uploadFile(token, smallPathAvif),
this.uploadFile(token, mediumPathAvif),
this.uploadFile(token, largePathAvif),
]) ])
ctx.body = await this.Media.create({ ctx.body = await this.Media.create({
@ -43,6 +49,9 @@ export default class MediaRoutes {
small_image: small.path, small_image: small.path,
medium_image: medium.path, medium_image: medium.path,
large_image: large.path, large_image: large.path,
small_image_avif: smallAvif.path,
medium_image_avif: mediumAvif.path,
large_image_avif: largeAvif.path,
org_image: org.path, org_image: org.path,
size: result.size, size: result.size,
staff_id: ctx.state.user.id, staff_id: ctx.state.user.id,

View file

@ -14,7 +14,6 @@ const router = new Router()
// API Authentication // API Authentication
const authentication = new AuthRoutes() const authentication = new AuthRoutes()
router.post('/api/login', authentication.login.bind(authentication))
router.post('/api/login/user', authentication.loginUser.bind(authentication)) router.post('/api/login/user', authentication.loginUser.bind(authentication))
// API Media // API Media

View file

@ -24,13 +24,19 @@ function mapArticle(trim = false, x, includeBanner = false, includeFiles = true)
media: x.media && ({ media: x.media && ({
link: !trim && x.media.link || null, link: !trim && x.media.link || null,
large_url: x.media.large_url, large_url: x.media.large_url,
large_url_avif: x.media.large_url_avif,
medium_url: x.media.medium_url, medium_url: x.media.medium_url,
medium_url_avif: x.media.medium_url_avif,
small_url: x.media.small_url, small_url: x.media.small_url,
small_url_avif: x.media.small_url_avif,
}) || null, }) || null,
banner: x.banner && includeBanner && ({ banner: x.banner && includeBanner && ({
large_url: x.banner.large_url, large_url: x.banner.large_url,
large_url_avif: x.banner.large_url_avif,
medium_url: x.banner.medium_url, medium_url: x.banner.medium_url,
medium_url_avif: x.banner.medium_url_avif,
small_url: x.banner.small_url, small_url: x.banner.small_url,
small_url_avif: x.banner.small_url_avif,
}) || null, }) || null,
parent: x.parent && ({ parent: x.parent && ({
id: x.parent.id, id: x.parent.id,
@ -67,8 +73,11 @@ function mapPage(x) {
media: x.media && ({ media: x.media && ({
link: x.media.link, link: x.media.link,
large_url: x.media.large_url, large_url: x.media.large_url,
large_url_avif: x.media.large_url_avif,
medium_url: x.media.medium_url, medium_url: x.media.medium_url,
medium_url_avif: x.media.medium_url_avif,
small_url: x.media.small_url, small_url: x.media.small_url,
small_url_avif: x.media.small_url_avif,
}) || null, }) || null,
parent: x.parent && ({ parent: x.parent && ({
id: x.parent.id, id: x.parent.id,
@ -77,8 +86,11 @@ function mapPage(x) {
}), }),
banner: x.banner && ({ banner: x.banner && ({
large_url: x.banner.large_url, large_url: x.banner.large_url,
large_url_avif: x.banner.large_url_avif,
medium_url: x.banner.medium_url, medium_url: x.banner.medium_url,
medium_url_avif: x.banner.medium_url_avif,
small_url: x.banner.small_url, small_url: x.banner.small_url,
small_url_avif: x.banner.small_url_avif,
}) || null, }) || null,
children: x.children && x.children.map(f => ({ children: x.children && x.children.map(f => ({
id: f.id, id: f.id,
@ -94,7 +106,8 @@ export async function serveIndex(ctx, path) {
let links = null let links = null
let featured = null let featured = null
let url = frontend + ctx.request.url let url = frontend + ctx.request.url
let image = frontend + '/assets/img/heart.jpg' let image = frontend + '/assets/img/heart.png'
let image_avif = frontend + '/assets/img/heart.png'
let title = 'NFP Moe - Anime/Manga translation group' let title = 'NFP Moe - Anime/Manga translation group'
let description = 'Small fansubbing and scanlation group translating and encoding our favourite shows from Japan.' let description = 'Small fansubbing and scanlation group translating and encoding our favourite shows from Japan.'
try { try {
@ -147,8 +160,10 @@ export async function serveIndex(ctx, path) {
if (found) { if (found) {
if (found.media) { if (found.media) {
image = found.media.large_url image = found.media.large_url
image_avif = found.media.large_url_avifl
} else if (found.banner) { } else if (found.banner) {
image = found.banner.large_url image = found.banner.large_url
image_avif = found.banner.large_url_avifl
} }
if (found.description) { if (found.description) {
description = striptags(found.description) description = striptags(found.description)
@ -174,6 +189,7 @@ export async function serveIndex(ctx, path) {
featured: JSON.stringify(featured), featured: JSON.stringify(featured),
url: url, url: url,
image: image, image: image,
image_avif: image_avif,
title: title, title: title,
description: description, description: description,
}) })

View file

@ -2,7 +2,7 @@ import _ from 'lodash'
import config from './config.mjs' import config from './config.mjs'
import log from './log.mjs' import log from './log.mjs'
import knex from 'knex' import knex from 'knex-core'
// This is important for setup to run cleanly. // This is important for setup to run cleanly.
let knexConfig = _.cloneDeep(config.get('knex')) let knexConfig = _.cloneDeep(config.get('knex'))

View file

@ -3,8 +3,6 @@ const storageName = 'logintoken'
const Authentication = { const Authentication = {
currentUser: null, currentUser: null,
isAdmin: false, isAdmin: false,
loadedGoogle: false,
loadingGoogle: false,
loadingListeners: [], loadingListeners: [],
authListeners: [], authListeners: [],
@ -32,38 +30,11 @@ const Authentication = {
Authentication.isAdmin = item Authentication.isAdmin = item
}, },
createGoogleScript: function() {
if (Authentication.loadedGoogle) return Promise.resolve()
return new Promise(function (res) {
if (Authentication.loadedGoogle) return res()
Authentication.loadingListeners.push(res)
if (Authentication.loadingGoogle) return
Authentication.loadingGoogle = true
let gscript = document.createElement('script')
gscript.type = 'text/javascript'
gscript.async = true
gscript.defer = true
gscript.src = 'https://apis.google.com/js/platform.js?onload=googleLoaded'
document.body.appendChild(gscript)
})
},
getToken: function() { getToken: function() {
return localStorage.getItem(storageName) return localStorage.getItem(storageName)
}, },
} }
if (!window.googleLoaded) {
window.googleLoaded = function() {
Authentication.loadedGoogle = true
while (Authentication.loadingListeners.length) {
Authentication.loadingListeners.pop()()
}
}
}
Authentication.updateToken(localStorage.getItem(storageName)) Authentication.updateToken(localStorage.getItem(storageName))
module.exports = Authentication module.exports = Authentication

View file

@ -90,12 +90,18 @@ const Frontpage = {
if (this.featured && this.featured.banner) { if (this.featured && this.featured.banner) {
var pixelRatio = window.devicePixelRatio || 1 var pixelRatio = window.devicePixelRatio || 1
if (deviceWidth < 400 && pixelRatio <= 1) { if (deviceWidth < 400 && pixelRatio <= 1) {
bannerPath = this.featured.banner.small_url bannerPath = window.supportsavif
&& this.featured.banner.small_url_avif
|| this.featured.banner.small_url
} else if ((deviceWidth < 800 && pixelRatio <= 1) } else if ((deviceWidth < 800 && pixelRatio <= 1)
|| (deviceWidth < 600 && pixelRatio > 1)) { || (deviceWidth < 600 && pixelRatio > 1)) {
bannerPath = this.featured.banner.medium_url bannerPath = window.supportsavif
&& this.featured.banner.medium_url_avif
|| this.featured.banner.medium_url
} else { } else {
bannerPath = this.featured.banner.large_url bannerPath = window.supportsavif
&& this.featured.banner.large_url_avif
|| this.featured.banner.large_url
} }
} }

View file

@ -3,6 +3,15 @@ require('./polyfill')
const m = require('mithril') const m = require('mithril')
window.m = m window.m = m
/*
* imgsupport.js from leechy/imgsupport
*/
const AVIF = new Image();
AVIF.onload = AVIF.onerror = function () {
window.supportsavif = (AVIF.height === 2)
}
AVIF.src = 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A=';
m.route.setOrig = m.route.set m.route.setOrig = m.route.set
m.route.set = function(path, data, options){ m.route.set = function(path, data, options){
m.route.setOrig(path, data, options) m.route.setOrig(path, data, options)

View file

@ -3,54 +3,10 @@ const Authentication = require('../authentication')
const Api = require('../api/common') const Api = require('../api/common')
const Login = { const Login = {
loadedGoogle: false,
loading: false, loading: false,
redirect: '', redirect: '',
error: '', error: '',
initGoogleButton: function() {
gapi.signin2.render('googlesignin', {
'scope': 'email',
'width': 240,
'height': 50,
'longtitle': true,
'theme': 'dark',
'onsuccess': Login.onGoogleSuccess,
'onfailure': Login.onGoogleFailure,
})
},
onGoogleSuccess: function(googleUser) {
Login.loading = true
m.redraw()
m.request({
method: 'POST',
url: '/api/login',
body: { token: googleUser.Zi.access_token },
})
.then(function(result) {
Authentication.updateToken(result.token)
m.route.set(Login.redirect || '/')
})
.catch(function(error) {
Login.error = 'Error while logging into NFP! ' + error.status + ': ' + error.message
let auth2 = gapi.auth2.getAuthInstance()
return auth2.signOut()
})
.then(function () {
Login.loading = false
m.redraw()
})
},
onGoogleFailure: function(error) {
if (error.error !== 'popup_closed_by_user' && error.error !== 'popup_blocked_by_browser') {
Login.error = 'Error while logging into Google: ' + error.error
m.redraw()
}
},
oninit: function(vnode) { oninit: function(vnode) {
Login.redirect = vnode.attrs.redirect || '' Login.redirect = vnode.attrs.redirect || ''
if (Authentication.currentUser) return m.route.set('/') if (Authentication.currentUser) return m.route.set('/')
@ -62,10 +18,6 @@ const Login = {
oncreate: function() { oncreate: function() {
if (Authentication.currentUser) return if (Authentication.currentUser) return
Authentication.createGoogleScript()
.then(function() {
Login.initGoogleButton()
})
}, },
loginuser: function(vnode, e) { loginuser: function(vnode, e) {
@ -136,8 +88,6 @@ const Login = {
value: 'Login', value: 'Login',
}), }),
]), ]),
m('h5', { hidden: Login.loading }, 'Alternative login'),
m('div#googlesignin', { hidden: Login.loading }, m('div.loading-spinner')),
]), ]),
]), ]),
]), ]),

View file

@ -3,23 +3,8 @@ const Authentication = require('../authentication')
const Logout = { const Logout = {
oninit: function() { oninit: function() {
Authentication.createGoogleScript() Authentication.clearToken()
.then(function() { m.route.set('/')
return new Promise(function (res) {
gapi.load('auth2', res)
})
})
.then(function() { return gapi.auth2.init() })
.then(function() {
let auth2 = gapi.auth2.getAuthInstance()
return auth2.signOut()
})
.then(function() {
Authentication.clearToken()
m.route.set('/')
}, function(err) {
console.log('unable to logout:', err)
})
}, },
view: function() { view: function() {

View file

@ -3,6 +3,8 @@ const Fileinfo = require('./fileinfo')
const Newsitem = { const Newsitem = {
view: function(vnode) { view: function(vnode) {
var pixelRatio = window.devicePixelRatio || 1 var pixelRatio = window.devicePixelRatio || 1
var jpegImage = pixelRatio > 1 ? vnode.attrs.media.medium_url : vnode.attrs.media.small_url
var avifImage = pixelRatio > 1 ? vnode.attrs.media.medium_url_avif : vnode.attrs.media.small_url_avif
return m('newsitem', [ return m('newsitem', [
m(m.route.Link, m(m.route.Link,
{ href: '/article/' + vnode.attrs.path, class: 'title' }, { href: '/article/' + vnode.attrs.path, class: 'title' },
@ -12,7 +14,14 @@ const Newsitem = {
vnode.attrs.media vnode.attrs.media
? m('a.cover', { ? m('a.cover', {
href: '/article/' + vnode.attrs.path, href: '/article/' + vnode.attrs.path,
}, m('img', { alt: 'Image for news item ' + vnode.attrs.name, src: pixelRatio > 1 ? vnode.attrs.media.medium_url : vnode.attrs.media.small_url })) },
m('picture', [
avifImage ? m('source', {
srcset: avifImage,
type: 'image/avif',
}) : null,
m('img', { alt: 'Image for news item ' + vnode.attrs.name, src: jpegImage })
]))
: null, : null,
m('div.entrycontent', { m('div.entrycontent', {
class: vnode.attrs.media ? '' : 'extrapadding', class: vnode.attrs.media ? '' : 'extrapadding',

View file

@ -0,0 +1,20 @@
/* eslint-disable */
exports.up = function(knex) {
return Promise.all([
knex.schema.table('media', function(table) {
table.text('small_image_avif')
table.text('medium_image_avif')
table.text('large_image_avif')
})
])
};
exports.down = function(knex) {
return Promise.all([
knex.schema.table('media', function(table) {
table.dropColumn('small_image_avif')
table.dropColumn('medium_image_avif')
table.dropColumn('large_image_avif')
})
])
};

View file

@ -46,27 +46,26 @@
"homepage": "https://github.com/nfp-projects/nfp_moe", "homepage": "https://github.com/nfp-projects/nfp_moe",
"dependencies": { "dependencies": {
"@koa/cors": "^2.2.3", "@koa/cors": "^2.2.3",
"bcrypt": "^3.0.0", "bcrypt": "^3.0.8",
"bookshelf": "^0.15.1", "bookshelf": "^0.15.1",
"bunyan-lite": "^1.0.1", "bunyan-lite": "^1.0.1",
"dot": "^1.1.2", "dot": "^1.1.2",
"format-link-header": "^2.1.0", "format-link-header": "^2.1.0",
"googleapis": "^42.0.0",
"http-errors": "^1.7.2", "http-errors": "^1.7.2",
"json-mask": "^0.3.8", "json-mask": "^0.3.8",
"jsonwebtoken": "^8.4.0", "jsonwebtoken": "^8.4.0",
"knex": "^0.16.3", "knex-core": "^0.19.5",
"koa": "^2.7.0",
"koa-bodyparser": "^4.2.1", "koa-bodyparser": "^4.2.1",
"koa-jwt": "^3.5.1", "koa-jwt": "^3.5.1",
"koa-lite": "^2.10.1",
"koa-router": "^7.4.0", "koa-router": "^7.4.0",
"koa-send": "^5.0.0", "koa-send": "^5.0.0",
"lodash": "^4.17.11", "lodash": "^4.17.11",
"multer": "^1.4.1", "multer": "^1.4.1",
"nconf": "^0.10.0", "nconf-lite": "^1.0.1",
"parse-torrent": "^7.0.1", "parse-torrent": "^7.0.1",
"pg": "^7.8.0", "pg": "^7.8.0",
"sharp": "^0.22.1", "sharp": "^0.27.0",
"striptags": "^3.1.1" "striptags": "^3.1.1"
}, },
"devDependencies": { "devDependencies": {

BIN
public/assets/img/heart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

View file

@ -1,4 +1,4 @@
import Koa from 'koa' import Koa from 'koa-lite'
import bodyParser from 'koa-bodyparser' import bodyParser from 'koa-bodyparser'
import cors from '@koa/cors' import cors from '@koa/cors'