aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorPitu <[email protected]>2021-01-04 01:04:20 +0900
committerPitu <[email protected]>2021-01-04 01:04:20 +0900
commitfcd39dc550dec8dbcb8325e07e938c5024cbc33d (patch)
treef41acb4e0d5fd3c3b1236fe4324b3fef9ec6eafe /src
parentCreate FUNDING.yml (diff)
parentchore: update todo (diff)
downloadhost.fuwn.me-fcd39dc550dec8dbcb8325e07e938c5024cbc33d.tar.xz
host.fuwn.me-fcd39dc550dec8dbcb8325e07e938c5024cbc33d.zip
Merge branch 'dev'
Diffstat (limited to 'src')
-rw-r--r--src/api/database/migrations/20190221225812_initialMigration.js93
-rw-r--r--src/api/database/migrations/20201227023216_addUniques.js33
-rw-r--r--src/api/database/seeds/initial.js30
-rw-r--r--src/api/databaseMigration.js136
-rw-r--r--src/api/routes/admin/banIP.js25
-rw-r--r--src/api/routes/admin/fileGET.js32
-rw-r--r--src/api/routes/admin/unBanIP.js27
-rw-r--r--src/api/routes/admin/userDemote.js28
-rw-r--r--src/api/routes/admin/userDisable.js28
-rw-r--r--src/api/routes/admin/userEnable.js28
-rw-r--r--src/api/routes/admin/userGET.js37
-rw-r--r--src/api/routes/admin/userPromote.js28
-rw-r--r--src/api/routes/admin/userPurge.js26
-rw-r--r--src/api/routes/admin/usersGET.js23
-rw-r--r--src/api/routes/albums/albumDELETE.js38
-rw-r--r--src/api/routes/albums/albumEditPOST.js33
-rw-r--r--src/api/routes/albums/albumFullGET.js58
-rw-r--r--src/api/routes/albums/albumGET.js46
-rw-r--r--src/api/routes/albums/albumPOST.js39
-rw-r--r--src/api/routes/albums/albumPurgeDELETE.js29
-rw-r--r--src/api/routes/albums/albumZipGET.js89
-rw-r--r--src/api/routes/albums/albumsGET.js71
-rw-r--r--src/api/routes/albums/link/linkDELETE.js35
-rw-r--r--src/api/routes/albums/link/linkEditPOST.js38
-rw-r--r--src/api/routes/albums/link/linkPOST.js78
-rw-r--r--src/api/routes/albums/link/linksGET.js22
-rw-r--r--src/api/routes/auth/loginPOST.js56
-rw-r--r--src/api/routes/auth/registerPOST.js59
-rw-r--r--src/api/routes/files/albumAddPOST.js33
-rw-r--r--src/api/routes/files/albumDelPOST.js34
-rw-r--r--src/api/routes/files/fileDELETE.js33
-rw-r--r--src/api/routes/files/fileGET.js46
-rw-r--r--src/api/routes/files/filesAlbumsGET.js34
-rw-r--r--src/api/routes/files/filesGET.js44
-rw-r--r--src/api/routes/files/tagAddBatchPOST.js40
-rw-r--r--src/api/routes/files/tagAddPOST.js36
-rw-r--r--src/api/routes/files/tagDelPOST.js38
-rw-r--r--src/api/routes/search/searchGET.js63
-rw-r--r--src/api/routes/service/configGET.js27
-rw-r--r--src/api/routes/service/restartPOST.js14
-rw-r--r--src/api/routes/service/versionGET.js15
-rw-r--r--src/api/routes/tags/tagDELETE.js37
-rw-r--r--src/api/routes/tags/tagPOST.js36
-rw-r--r--src/api/routes/tags/tagsGET.js30
-rw-r--r--src/api/routes/uploads/chunksPOST.js99
-rw-r--r--src/api/routes/uploads/uploadPOST.js156
-rw-r--r--src/api/routes/user/apiKey.js34
-rw-r--r--src/api/routes/user/changePasswordPOST.js46
-rw-r--r--src/api/routes/user/userGET.js21
-rw-r--r--src/api/routes/verifyGET.js20
-rw-r--r--src/api/structures/Route.js110
-rw-r--r--src/api/structures/Server.js111
-rw-r--r--src/api/utils/Log.js36
-rw-r--r--src/api/utils/QueryHelper.js200
-rw-r--r--src/api/utils/ThumbUtil.js104
-rw-r--r--src/api/utils/Util.js296
-rw-r--r--src/api/utils/generateThumbs.js17
-rw-r--r--src/api/utils/videoPreview/FragmentPreview.js88
-rw-r--r--src/api/utils/videoPreview/FrameIntervalPreview.js73
-rw-r--r--src/setup.js167
-rw-r--r--src/site/assets/images/background.jpgbin0 -> 75515 bytes
-rw-r--r--src/site/assets/images/blank.pngbin0 -> 1479 bytes
-rw-r--r--src/site/assets/images/blank2.jpgbin0 -> 14245 bytes
-rw-r--r--src/site/assets/images/blank_darker.pngbin0 -> 862 bytes
-rw-r--r--src/site/assets/images/logo.pngbin0 -> 11306 bytes
-rw-r--r--src/site/assets/styles/_bulma_colors_extender.scss16
-rw-r--r--src/site/assets/styles/_colors.scss83
-rw-r--r--src/site/assets/styles/bulma-divider.scss164
-rw-r--r--src/site/assets/styles/dropzone.scss575
-rw-r--r--src/site/assets/styles/style.scss414
-rw-r--r--src/site/components/album/AlbumDetails.vue294
-rw-r--r--src/site/components/album/AlbumEntry.vue182
-rw-r--r--src/site/components/footer/Footer.test.js25
-rw-r--r--src/site/components/footer/Footer.vue110
-rw-r--r--src/site/components/grid/Grid.vue498
-rw-r--r--src/site/components/grid/waterfall/Waterfall.vue129
-rw-r--r--src/site/components/grid/waterfall/WaterfallItem.vue10
-rw-r--r--src/site/components/home/links/Links.vue142
-rw-r--r--src/site/components/image-modal/AlbumInfo.vue90
-rw-r--r--src/site/components/image-modal/ImageInfo.vue211
-rw-r--r--src/site/components/image-modal/TagInfo.vue95
-rw-r--r--src/site/components/loading/BulmaLoading.vue33
-rw-r--r--src/site/components/loading/CubeShadow.vue49
-rw-r--r--src/site/components/loading/Origami.vue123
-rw-r--r--src/site/components/loading/PingPong.vue100
-rw-r--r--src/site/components/loading/RotateSquare.vue88
-rw-r--r--src/site/components/logo/Logo.vue27
-rw-r--r--src/site/components/navbar/Navbar.vue137
-rw-r--r--src/site/components/search-input/SearchInput.vue517
-rw-r--r--src/site/components/search/Search.vue145
-rw-r--r--src/site/components/sidebar/Sidebar.vue82
-rw-r--r--src/site/components/uploader/Uploader.vue258
-rw-r--r--src/site/constants/alertTypes.js10
-rw-r--r--src/site/layouts/default.vue106
-rw-r--r--src/site/layouts/error.vue28
-rw-r--r--src/site/middleware/admin.js6
-rw-r--r--src/site/middleware/auth.js7
-rw-r--r--src/site/pages/a/_identifier.vue153
-rw-r--r--src/site/pages/dashboard/account.vue174
-rw-r--r--src/site/pages/dashboard/admin/file/_id.vue176
-rw-r--r--src/site/pages/dashboard/admin/settings.vue162
-rw-r--r--src/site/pages/dashboard/admin/user/_id.vue120
-rw-r--r--src/site/pages/dashboard/admin/users.vue247
-rw-r--r--src/site/pages/dashboard/albums/_id.vue128
-rw-r--r--src/site/pages/dashboard/albums/index.vue110
-rw-r--r--src/site/pages/dashboard/index.vue144
-rw-r--r--src/site/pages/dashboard/tags/index.vue270
-rw-r--r--src/site/pages/faq.vue66
-rw-r--r--src/site/pages/index.vue102
-rw-r--r--src/site/pages/login.vue156
-rw-r--r--src/site/pages/logout.vue8
-rw-r--r--src/site/pages/register.vue115
-rw-r--r--src/site/plugins/axios.js32
-rw-r--r--src/site/plugins/buefy.js5
-rw-r--r--src/site/plugins/handler.js25
-rw-r--r--src/site/plugins/notifier.js25
-rw-r--r--src/site/plugins/nuxt-client-init.js3
-rw-r--r--src/site/plugins/v-clipboard.js4
-rw-r--r--src/site/plugins/vue-isyourpasswordsafe.js7
-rw-r--r--src/site/plugins/vue-timeago.js8
-rw-r--r--src/site/plugins/vuebar.js4
-rw-r--r--src/site/static/oembed.json6
-rw-r--r--src/site/store/.eslintrc.json5
-rw-r--r--src/site/store/admin.js122
-rw-r--r--src/site/store/albums.js148
-rw-r--r--src/site/store/alert.js33
-rw-r--r--src/site/store/auth.js106
-rw-r--r--src/site/store/config.js18
-rw-r--r--src/site/store/images.js193
-rw-r--r--src/site/store/index.js13
-rw-r--r--src/site/store/tags.js40
-rw-r--r--src/tests/api/verify.test.js12
-rw-r--r--src/tests/e2e/index.test.js20
-rw-r--r--src/tests/utils.js11
134 files changed, 11128 insertions, 0 deletions
diff --git a/src/api/database/migrations/20190221225812_initialMigration.js b/src/api/database/migrations/20190221225812_initialMigration.js
new file mode 100644
index 0000000..a27a08a
--- /dev/null
+++ b/src/api/database/migrations/20190221225812_initialMigration.js
@@ -0,0 +1,93 @@
+exports.up = async knex => {
+ await knex.schema.createTable('users', table => {
+ table.increments();
+ table.string('username');
+ table.text('password');
+ table.boolean('enabled');
+ table.boolean('isAdmin');
+ table.string('apiKey');
+ table.timestamp('passwordEditedAt');
+ table.timestamp('apiKeyEditedAt');
+ table.timestamp('createdAt');
+ table.timestamp('editedAt');
+ });
+
+ await knex.schema.createTable('albums', table => {
+ table.increments();
+ table.integer('userId');
+ table.string('name');
+ table.timestamp('zippedAt');
+ table.timestamp('createdAt');
+ table.timestamp('editedAt');
+ });
+
+ await knex.schema.createTable('files', table => {
+ table.increments();
+ table.integer('userId');
+ table.string('name');
+ table.string('original');
+ table.string('type');
+ table.integer('size');
+ table.string('hash');
+ table.string('ip');
+ table.timestamp('createdAt');
+ table.timestamp('editedAt');
+ });
+
+ await knex.schema.createTable('links', table => {
+ table.increments();
+ table.integer('userId');
+ table.integer('albumId');
+ table.string('identifier');
+ table.integer('views');
+ table.boolean('enabled');
+ table.boolean('enableDownload');
+ table.timestamp('expiresAt');
+ table.timestamp('createdAt');
+ table.timestamp('editedAt');
+ });
+
+ await knex.schema.createTable('albumsFiles', table => {
+ table.increments();
+ table.integer('albumId');
+ table.integer('fileId');
+ });
+
+ await knex.schema.createTable('albumsLinks', table => {
+ table.increments();
+ table.integer('albumId');
+ table.integer('linkId');
+ });
+
+ await knex.schema.createTable('tags', table => {
+ table.increments();
+ table.string('uuid');
+ table.integer('userId');
+ table.string('name');
+ table.timestamp('createdAt');
+ table.timestamp('editedAt');
+ });
+
+ await knex.schema.createTable('fileTags', table => {
+ table.increments();
+ table.integer('fileId');
+ table.integer('tagId');
+ });
+
+ await knex.schema.createTable('bans', table => {
+ table.increments();
+ table.string('ip');
+ table.timestamp('createdAt');
+ });
+};
+exports.down = async knex => {
+ await knex.schema.dropTableIfExists('users');
+ await knex.schema.dropTableIfExists('albums');
+ await knex.schema.dropTableIfExists('files');
+ await knex.schema.dropTableIfExists('links');
+ await knex.schema.dropTableIfExists('albumsFiles');
+ await knex.schema.dropTableIfExists('albumsLinks');
+ await knex.schema.dropTableIfExists('tags');
+ await knex.schema.dropTableIfExists('fileTags');
+ await knex.schema.dropTableIfExists('bans');
+};
diff --git a/src/api/database/migrations/20201227023216_addUniques.js b/src/api/database/migrations/20201227023216_addUniques.js
new file mode 100644
index 0000000..14f9e7f
--- /dev/null
+++ b/src/api/database/migrations/20201227023216_addUniques.js
@@ -0,0 +1,33 @@
+exports.up = async knex => {
+ await knex.schema.alterTable('users', table => {
+ table.unique(['username', 'apiKey']);
+ });
+
+ await knex.schema.alterTable('albums', table => {
+ table.boolean('nsfw').defaultTo(false);
+ table.unique(['userId', 'name']);
+ });
+
+ await knex.schema.alterTable('links', table => {
+ table.unique(['userId', 'albumId', 'identifier']);
+ });
+
+ await knex.schema.alterTable('albumsFiles', table => {
+ table.unique(['albumId', 'fileId']);
+ });
+
+ await knex.schema.alterTable('albumsLinks', table => {
+ table.unique(['linkId']);
+ });
+
+ await knex.schema.alterTable('tags', table => {
+ table.unique(['userId', 'name']);
+ });
+
+ await knex.schema.alterTable('fileTags', table => {
+ table.unique(['fileId', 'tagId']);
+ });
+};
+exports.down = async knex => {
+ // Nothing
+};
diff --git a/src/api/database/seeds/initial.js b/src/api/database/seeds/initial.js
new file mode 100644
index 0000000..edc1949
--- /dev/null
+++ b/src/api/database/seeds/initial.js
@@ -0,0 +1,30 @@
+/* eslint-disable no-console */
+const bcrypt = require('bcrypt');
+const moment = require('moment');
+
+exports.seed = async db => {
+ const now = moment.utc().toDate();
+ const user = await db.table('users').where({ username: process.env.ADMIN_ACCOUNT }).first();
+ if (user) return;
+ try {
+ const hash = await bcrypt.hash(process.env.ADMIN_PASSWORD, 10);
+ await db.table('users').insert({
+ username: process.env.ADMIN_ACCOUNT,
+ password: hash,
+ passwordEditedAt: now,
+ createdAt: now,
+ editedAt: now,
+ enabled: true,
+ isAdmin: true
+ });
+ console.log();
+ console.log('=========================================================');
+ console.log('== Successfully created the admin account. ==');
+ console.log('=========================================================');
+ console.log('== Run `pm2 start pm2.json` to start the service ==');
+ console.log('=========================================================');
+ console.log();
+ } catch (error) {
+ console.error(error);
+ }
+};
diff --git a/src/api/databaseMigration.js b/src/api/databaseMigration.js
new file mode 100644
index 0000000..71ee2e6
--- /dev/null
+++ b/src/api/databaseMigration.js
@@ -0,0 +1,136 @@
+const nodePath = require('path');
+const moment = require('moment');
+const jetpack = require('fs-jetpack');
+const ThumbUtil = require('./utils/ThumbUtil');
+
+const oldDb = require('knex')({
+ client: 'sqlite3',
+ connection: {
+ filename: nodePath.join(__dirname, '../../', 'db')
+ },
+ useNullAsDefault: true
+});
+
+const newDb = require('knex')({
+ client: 'sqlite3',
+ connection: {
+ filename: nodePath.join(__dirname, '../../database/', 'database.sqlite')
+ },
+ postProcessResponse: result => {
+ const booleanFields = [
+ 'enabled',
+ 'enableDownload',
+ 'isAdmin',
+ 'nsfw'
+ ];
+
+ const processResponse = row => {
+ Object.keys(row).forEach(key => {
+ if (booleanFields.includes(key)) {
+ if (row[key] === 0) row[key] = false;
+ else if (row[key] === 1) row[key] = true;
+ }
+ });
+ return row;
+ };
+
+ if (Array.isArray(result)) return result.map(row => processResponse(row));
+ if (typeof result === 'object') return processResponse(result);
+ return result;
+ },
+ useNullAsDefault: true
+});
+
+const start = async () => {
+ console.log('Starting migration, this may take a few minutes...'); // Because I half assed it
+ console.log('Please do NOT kill the process. Wait for it to finish.');
+
+ await jetpack.removeAsync(nodePath.join(__dirname, '../../uploads/thumbs'));
+ await jetpack.dirAsync(nodePath.join(__dirname, '../../uploads/thumbs/square'));
+ console.log('Finished deleting old thumbnails to create new ones');
+
+ const users = await oldDb.table('users').where('username', '<>', 'root');
+ for (const user of users) {
+ const now = moment.utc().toDate();
+ const userToInsert = {
+ id: user.id,
+ username: user.username,
+ password: user.password,
+ enabled: user.enabled == 1,
+ isAdmin: false,
+ apiKey: user.token,
+ passwordEditedAt: now,
+ apiKeyEditedAt: now,
+ createdAt: now,
+ editedAt: now
+ };
+ await newDb.table('users').insert(userToInsert);
+ }
+ console.log('Finished migrating users...');
+
+ const albums = await oldDb.table('albums');
+ for (const album of albums) {
+ if (!album.enabled || album.enabled == 0) continue;
+ const now = moment.utc().toDate();
+ const albumToInsert = {
+ id: album.id,
+ userId: album.userid,
+ name: album.name,
+ zippedAt: album.zipGeneratedAt ? moment.unix(album.zipGeneratedAt).toDate() : null,
+ createdAt: moment.unix(album.timestamp).toDate(),
+ editedAt: moment.unix(album.editedAt).toDate()
+ };
+ const linkToInsert = {
+ userId: album.userid,
+ albumId: album.id,
+ identifier: album.identifier,
+ views: 0,
+ enabled: true,
+ enableDownload: true,
+ createdAt: now,
+ editedAt: now
+ };
+ await newDb.table('albums').insert(albumToInsert);
+ const insertedId = await newDb.table('links').insert(linkToInsert);
+ await newDb.table('albumsLinks').insert({
+ albumId: album.id,
+ linkId: insertedId[0]
+ });
+ }
+ console.log('Finished migrating albums...');
+
+ const files = await oldDb.table('files');
+ const filesToInsert = [];
+ const albumsFilesToInsert = [];
+ for (const file of files) {
+ const fileToInsert = {
+ id: file.id,
+ userId: file.userid,
+ name: file.name,
+ original: file.original,
+ type: file.type,
+ size: file.size,
+ hash: file.hash,
+ ip: file.ip,
+ createdAt: moment.unix(file.timestamp).toDate(),
+ editedAt: moment.unix(file.timestamp).toDate()
+ };
+ filesToInsert.push(fileToInsert);
+ albumsFilesToInsert.push({
+ albumId: file.albumid,
+ fileId: file.id
+ });
+
+ const filename = file.name;
+ if (!jetpack.exists(nodePath.join(__dirname, '../../uploads', filename))) continue;
+ ThumbUtil.generateThumbnails(filename);
+ }
+ await newDb.batchInsert('files', filesToInsert, 20);
+ await newDb.batchInsert('albumsFiles', albumsFilesToInsert, 20);
+ console.log('Finished migrating files...');
+
+ console.log('Finished migrating everything. ');
+ process.exit(0);
+};
+
+start();
diff --git a/src/api/routes/admin/banIP.js b/src/api/routes/admin/banIP.js
new file mode 100644
index 0000000..692880d
--- /dev/null
+++ b/src/api/routes/admin/banIP.js
@@ -0,0 +1,25 @@
+const Route = require('../../structures/Route');
+
+class banIP extends Route {
+ constructor() {
+ super('/admin/ban/ip', 'post', { adminOnly: true });
+ }
+
+ async run(req, res, db) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { ip } = req.body;
+ if (!ip) return res.status(400).json({ message: 'No ip provided' });
+
+ try {
+ await db.table('bans').insert({ ip });
+ } catch (error) {
+ return super.error(res, error);
+ }
+
+ return res.json({
+ message: 'Successfully banned the ip'
+ });
+ }
+}
+
+module.exports = banIP;
diff --git a/src/api/routes/admin/fileGET.js b/src/api/routes/admin/fileGET.js
new file mode 100644
index 0000000..9605da4
--- /dev/null
+++ b/src/api/routes/admin/fileGET.js
@@ -0,0 +1,32 @@
+const Route = require('../../structures/Route');
+const Util = require('../../utils/Util');
+
+class filesGET extends Route {
+ constructor() {
+ super('/admin/file/:id', 'get', { adminOnly: true });
+ }
+
+ async run(req, res, db) {
+ const { id } = req.params;
+ if (!id) return res.status(400).json({ message: 'Invalid file ID supplied' });
+
+ let file = await db.table('files').where({ id }).first();
+ const user = await db.table('users')
+ .select('id', 'username', 'enabled', 'createdAt', 'editedAt', 'apiKeyEditedAt', 'isAdmin')
+ .where({ id: file.userId })
+ .first();
+ file = Util.constructFilePublicLink(file);
+
+ // Additional relevant data
+ const filesFromUser = await db.table('files').where({ userId: user.id }).select('id');
+ user.fileCount = filesFromUser.length;
+
+ return res.json({
+ message: 'Successfully retrieved file',
+ file,
+ user
+ });
+ }
+}
+
+module.exports = filesGET;
diff --git a/src/api/routes/admin/unBanIP.js b/src/api/routes/admin/unBanIP.js
new file mode 100644
index 0000000..493834b
--- /dev/null
+++ b/src/api/routes/admin/unBanIP.js
@@ -0,0 +1,27 @@
+const Route = require('../../structures/Route');
+
+class unBanIP extends Route {
+ constructor() {
+ super('/admin/unban/ip', 'post', { adminOnly: true });
+ }
+
+ async run(req, res, db) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { ip } = req.body;
+ if (!ip) return res.status(400).json({ message: 'No ip provided' });
+
+ try {
+ await db.table('bans')
+ .where({ ip })
+ .delete();
+ } catch (error) {
+ return super.error(res, error);
+ }
+
+ return res.json({
+ message: 'Successfully unbanned the ip'
+ });
+ }
+}
+
+module.exports = unBanIP;
diff --git a/src/api/routes/admin/userDemote.js b/src/api/routes/admin/userDemote.js
new file mode 100644
index 0000000..b430a48
--- /dev/null
+++ b/src/api/routes/admin/userDemote.js
@@ -0,0 +1,28 @@
+const Route = require('../../structures/Route');
+
+class userDemote extends Route {
+ constructor() {
+ super('/admin/users/demote', 'post', { adminOnly: true });
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { id } = req.body;
+ if (!id) return res.status(400).json({ message: 'No id provided' });
+ if (id === user.id) return res.status(400).json({ message: 'You can\'t apply this action to yourself' });
+
+ try {
+ await db.table('users')
+ .where({ id })
+ .update({ isAdmin: false });
+ } catch (error) {
+ return super.error(res, error);
+ }
+
+ return res.json({
+ message: 'Successfully demoted user'
+ });
+ }
+}
+
+module.exports = userDemote;
diff --git a/src/api/routes/admin/userDisable.js b/src/api/routes/admin/userDisable.js
new file mode 100644
index 0000000..e39c811
--- /dev/null
+++ b/src/api/routes/admin/userDisable.js
@@ -0,0 +1,28 @@
+const Route = require('../../structures/Route');
+
+class userDisable extends Route {
+ constructor() {
+ super('/admin/users/disable', 'post', { adminOnly: true });
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { id } = req.body;
+ if (!id) return res.status(400).json({ message: 'No id provided' });
+ if (id === user.id) return res.status(400).json({ message: 'You can\'t apply this action to yourself' });
+
+ try {
+ await db.table('users')
+ .where({ id })
+ .update({ enabled: false });
+ } catch (error) {
+ return super.error(res, error);
+ }
+
+ return res.json({
+ message: 'Successfully disabled user'
+ });
+ }
+}
+
+module.exports = userDisable;
diff --git a/src/api/routes/admin/userEnable.js b/src/api/routes/admin/userEnable.js
new file mode 100644
index 0000000..cff622f
--- /dev/null
+++ b/src/api/routes/admin/userEnable.js
@@ -0,0 +1,28 @@
+const Route = require('../../structures/Route');
+
+class userEnable extends Route {
+ constructor() {
+ super('/admin/users/enable', 'post', { adminOnly: true });
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { id } = req.body;
+ if (!id) return res.status(400).json({ message: 'No id provided' });
+ if (id === user.id) return res.status(400).json({ message: 'You can\'t apply this action to yourself' });
+
+ try {
+ await db.table('users')
+ .where({ id })
+ .update({ enabled: true });
+ } catch (error) {
+ return super.error(res, error);
+ }
+
+ return res.json({
+ message: 'Successfully enabled user'
+ });
+ }
+}
+
+module.exports = userEnable;
diff --git a/src/api/routes/admin/userGET.js b/src/api/routes/admin/userGET.js
new file mode 100644
index 0000000..48c6e9b
--- /dev/null
+++ b/src/api/routes/admin/userGET.js
@@ -0,0 +1,37 @@
+const Route = require('../../structures/Route');
+const Util = require('../../utils/Util');
+
+class usersGET extends Route {
+ constructor() {
+ super('/admin/users/:id', 'get', { adminOnly: true });
+ }
+
+ async run(req, res, db) {
+ const { id } = req.params;
+ if (!id) return res.status(400).json({ message: 'Invalid user ID supplied' });
+
+ try {
+ const user = await db.table('users')
+ .select('id', 'username', 'enabled', 'createdAt', 'editedAt', 'apiKeyEditedAt', 'isAdmin')
+ .where({ id })
+ .first();
+ const files = await db.table('files')
+ .where({ userId: user.id })
+ .orderBy('id', 'desc');
+
+ for (let file of files) {
+ file = Util.constructFilePublicLink(file);
+ }
+
+ return res.json({
+ message: 'Successfully retrieved user',
+ user,
+ files
+ });
+ } catch (error) {
+ return super.error(res, error);
+ }
+ }
+}
+
+module.exports = usersGET;
diff --git a/src/api/routes/admin/userPromote.js b/src/api/routes/admin/userPromote.js
new file mode 100644
index 0000000..4a5ed88
--- /dev/null
+++ b/src/api/routes/admin/userPromote.js
@@ -0,0 +1,28 @@
+const Route = require('../../structures/Route');
+
+class userPromote extends Route {
+ constructor() {
+ super('/admin/users/promote', 'post', { adminOnly: true });
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { id } = req.body;
+ if (!id) return res.status(400).json({ message: 'No id provided' });
+ if (id === user.id) return res.status(400).json({ message: 'You can\'t apply this action to yourself' });
+
+ try {
+ await db.table('users')
+ .where({ id })
+ .update({ isAdmin: true });
+ } catch (error) {
+ return super.error(res, error);
+ }
+
+ return res.json({
+ message: 'Successfully promoted user'
+ });
+ }
+}
+
+module.exports = userPromote;
diff --git a/src/api/routes/admin/userPurge.js b/src/api/routes/admin/userPurge.js
new file mode 100644
index 0000000..90f6ec9
--- /dev/null
+++ b/src/api/routes/admin/userPurge.js
@@ -0,0 +1,26 @@
+const Route = require('../../structures/Route');
+const Util = require('../../utils/Util');
+
+class userDemote extends Route {
+ constructor() {
+ super('/admin/users/purge', 'post', { adminOnly: true });
+ }
+
+ async run(req, res) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { id } = req.body;
+ if (!id) return res.status(400).json({ message: 'No id provided' });
+
+ try {
+ await Util.deleteAllFilesFromUser(id);
+ } catch (error) {
+ return super.error(res, error);
+ }
+
+ return res.json({
+ message: 'Successfully deleted the user\'s files'
+ });
+ }
+}
+
+module.exports = userDemote;
diff --git a/src/api/routes/admin/usersGET.js b/src/api/routes/admin/usersGET.js
new file mode 100644
index 0000000..52a707f
--- /dev/null
+++ b/src/api/routes/admin/usersGET.js
@@ -0,0 +1,23 @@
+const Route = require('../../structures/Route');
+
+class usersGET extends Route {
+ constructor() {
+ super('/admin/users', 'get', { adminOnly: true });
+ }
+
+ async run(req, res, db) {
+ try {
+ const users = await db.table('users')
+ .select('id', 'username', 'enabled', 'isAdmin', 'createdAt');
+
+ return res.json({
+ message: 'Successfully retrieved users',
+ users
+ });
+ } catch (error) {
+ return super.error(res, error);
+ }
+ }
+}
+
+module.exports = usersGET;
diff --git a/src/api/routes/albums/albumDELETE.js b/src/api/routes/albums/albumDELETE.js
new file mode 100644
index 0000000..f9c22e6
--- /dev/null
+++ b/src/api/routes/albums/albumDELETE.js
@@ -0,0 +1,38 @@
+const Route = require('../../structures/Route');
+
+class albumDELETE extends Route {
+ constructor() {
+ super('/album/:id', 'delete');
+ }
+
+ async run(req, res, db, user) {
+ const { id } = req.params;
+ if (!id) return res.status(400).json({ message: 'Invalid album ID supplied' });
+
+ /*
+ Check if the album exists
+ */
+ const album = await db.table('albums').where({ id, userId: user.id }).first();
+ if (!album) return res.status(400).json({ message: 'The album doesn\'t exist or doesn\'t belong to the user' });
+
+ try {
+ // Delete the album
+ await db.table('albums').where({ id }).delete();
+
+ // Delete the relation of any files attached to this album
+ await db.table('albumsFiles').where({ albumId: id }).delete();
+
+ // Delete the relation of any links attached to this album
+ await db.table('albumsLinks').where({ albumId: id }).delete();
+
+ // Delete any album links created for this album
+ await db.table('links').where({ albumId: id }).delete();
+
+ return res.json({ message: 'The album was deleted successfully' });
+ } catch (error) {
+ return super.error(res, error);
+ }
+ }
+}
+
+module.exports = albumDELETE;
diff --git a/src/api/routes/albums/albumEditPOST.js b/src/api/routes/albums/albumEditPOST.js
new file mode 100644
index 0000000..1022bbd
--- /dev/null
+++ b/src/api/routes/albums/albumEditPOST.js
@@ -0,0 +1,33 @@
+const Route = require('../../structures/Route');
+
+class albumEditPOST extends Route {
+ constructor() {
+ super('/album/edit', 'post');
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { id, name, nsfw } = req.body;
+ if (!id) return res.status(400).json({ message: 'Invalid album identifier supplied' });
+
+
+ const album = await db.table('albums').where({ id, userId: user.id }).first();
+ if (!album) return res.status(400).json({ message: 'The album doesn\'t exist or doesn\'t belong to the user' });
+
+ try {
+ const updateObj = {
+ name: name || album.name,
+ nsfw: nsfw === true ? true : nsfw === false ? false : album.nsfw
+ };
+ await db
+ .table('albums')
+ .where({ id })
+ .update(updateObj);
+ return res.json({ message: 'Editing the album was successful', data: updateObj });
+ } catch (error) {
+ return super.error(res, error);
+ }
+ }
+}
+
+module.exports = albumEditPOST;
diff --git a/src/api/routes/albums/albumFullGET.js b/src/api/routes/albums/albumFullGET.js
new file mode 100644
index 0000000..d25fe15
--- /dev/null
+++ b/src/api/routes/albums/albumFullGET.js
@@ -0,0 +1,58 @@
+const Route = require('../../structures/Route');
+const Util = require('../../utils/Util');
+
+class albumGET extends Route {
+ constructor() {
+ super('/album/:id/full', 'get');
+ }
+
+ async run(req, res, db, user) {
+ const { id } = req.params;
+ if (!id) return res.status(400).json({ message: 'Invalid id supplied' });
+
+ const album = await db
+ .table('albums')
+ .where({ id, userId: user.id })
+ .first();
+ if (!album) return res.status(404).json({ message: 'Album not found' });
+
+ let count = 0;
+
+ let files = db
+ .table('albumsFiles')
+ .where({ albumId: id })
+ .join('files', 'albumsFiles.fileId', 'files.id')
+ .select('files.id', 'files.name', 'files.createdAt')
+ .orderBy('files.id', 'desc');
+
+ const { page, limit = 100 } = req.query;
+ if (page && page >= 0) {
+ files = await files.offset((page - 1) * limit).limit(limit);
+
+ const dbRes = await db
+ .table('albumsFiles')
+ .count('* as count')
+ .where({ albumId: id })
+ .first();
+
+ count = dbRes.count;
+ } else {
+ files = await files; // execute the query
+ count = files.length;
+ }
+
+ // eslint-disable-next-line no-restricted-syntax
+ for (let file of files) {
+ file = Util.constructFilePublicLink(file);
+ }
+
+ return res.json({
+ message: 'Successfully retrieved album',
+ name: album.name,
+ files,
+ count
+ });
+ }
+}
+
+module.exports = albumGET;
diff --git a/src/api/routes/albums/albumGET.js b/src/api/routes/albums/albumGET.js
new file mode 100644
index 0000000..c9f6763
--- /dev/null
+++ b/src/api/routes/albums/albumGET.js
@@ -0,0 +1,46 @@
+const Route = require('../../structures/Route');
+const Util = require('../../utils/Util');
+
+class albumGET extends Route {
+ constructor() {
+ super('/album/:identifier', 'get', { bypassAuth: true });
+ }
+
+ async run(req, res, db) {
+ const { identifier } = req.params;
+ if (!identifier) return res.status(400).json({ message: 'Invalid identifier supplied' });
+
+ // Make sure it exists and it's enabled
+ const link = await db.table('links').where({ identifier, enabled: true }).first();
+ if (!link) return res.status(404).json({ message: 'The album could not be found' });
+
+ // Same with the album, just to make sure is not a deleted album and a leftover link
+ const album = await db.table('albums').where('id', link.albumId).first();
+ if (!album) return res.status(404).json({ message: 'Album not found' });
+
+ const files = await db.table('albumsFiles')
+ .where({ albumId: link.albumId })
+ .join('files', 'albumsFiles.fileId', 'files.id')
+ .select('files.name', 'files.id')
+ .orderBy('files.id', 'desc');
+
+ // Create the links for each file
+ // eslint-disable-next-line no-restricted-syntax
+ for (let file of files) {
+ file = Util.constructFilePublicLink(file);
+ }
+
+ // Add 1 more view to the link
+ await db.table('links').where({ identifier }).update('views', Number(link.views) + 1);
+
+ return res.json({
+ message: 'Successfully retrieved files',
+ name: album.name,
+ downloadEnabled: link.enableDownload,
+ isNsfw: album.nsfw,
+ files
+ });
+ }
+}
+
+module.exports = albumGET;
diff --git a/src/api/routes/albums/albumPOST.js b/src/api/routes/albums/albumPOST.js
new file mode 100644
index 0000000..52352a1
--- /dev/null
+++ b/src/api/routes/albums/albumPOST.js
@@ -0,0 +1,39 @@
+const moment = require('moment');
+const Route = require('../../structures/Route');
+
+class albumPOST extends Route {
+ constructor() {
+ super('/album/new', 'post');
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { name } = req.body;
+ if (!name) return res.status(400).json({ message: 'No name provided' });
+
+ /*
+ Check that an album with that name doesn't exist yet
+ */
+ const album = await db
+ .table('albums')
+ .where({ name, userId: user.id })
+ .first();
+ if (album) return res.status(401).json({ message: "There's already an album with that name" });
+
+ const now = moment.utc().toDate();
+ const insertObj = {
+ name,
+ userId: user.id,
+ createdAt: now,
+ editedAt: now
+ };
+
+ const dbRes = await db.table('albums').insert(insertObj);
+
+ insertObj.id = dbRes.pop();
+
+ return res.json({ message: 'The album was created successfully', data: insertObj });
+ }
+}
+
+module.exports = albumPOST;
diff --git a/src/api/routes/albums/albumPurgeDELETE.js b/src/api/routes/albums/albumPurgeDELETE.js
new file mode 100644
index 0000000..a63eafc
--- /dev/null
+++ b/src/api/routes/albums/albumPurgeDELETE.js
@@ -0,0 +1,29 @@
+const Route = require('../../structures/Route');
+const Util = require('../../utils/Util');
+
+class albumDELETE extends Route {
+ constructor() {
+ super('/album/:id/purge', 'delete');
+ }
+
+ async run(req, res, db, user) {
+ const { id } = req.params;
+ if (!id) return res.status(400).json({ message: 'Invalid album ID supplied' });
+
+ /*
+ Check if the album exists
+ */
+ const album = await db.table('albums').where({ id, userId: user.id }).first();
+ if (!album) return res.status(400).json({ message: 'The album doesn\'t exist or doesn\'t belong to the user' });
+
+ try {
+ await Util.deleteAllFilesFromAlbum(id);
+ await db.table('albums').where({ id }).delete();
+ return res.json({ message: 'The album was deleted successfully' });
+ } catch (error) {
+ return super.error(res, error);
+ }
+ }
+}
+
+module.exports = albumDELETE;
diff --git a/src/api/routes/albums/albumZipGET.js b/src/api/routes/albums/albumZipGET.js
new file mode 100644
index 0000000..c560cff
--- /dev/null
+++ b/src/api/routes/albums/albumZipGET.js
@@ -0,0 +1,89 @@
+const path = require('path');
+const jetpack = require('fs-jetpack');
+const Route = require('../../structures/Route');
+const Util = require('../../utils/Util');
+const log = require('../../utils/Log');
+
+class albumGET extends Route {
+ constructor() {
+ super('/album/:identifier/zip', 'get', { bypassAuth: true });
+ }
+
+ async run(req, res, db) {
+ const { identifier } = req.params;
+ if (!identifier) return res.status(400).json({ message: 'Invalid identifier supplied' });
+
+ // TODO: Do we really want to let anyone create a zip of an album?
+ /*
+ Make sure it exists and it's enabled
+ */
+ const link = await db.table('links')
+ .where({
+ identifier,
+ enabled: true,
+ enableDownload: true
+ })
+ .first();
+ if (!link) return res.status(400).json({ message: 'The supplied identifier could not be found' });
+
+ /*
+ Same with the album, just to make sure is not a deleted album and a leftover link
+ */
+ const album = await db.table('albums')
+ .where('id', link.albumId)
+ .first();
+ if (!album) return res.status(400).json({ message: 'Album not found' });
+
+ /*
+ If the date when the album was zipped is greater than the album's last edit, we just send the zip to the user
+ */
+ if (album.zippedAt > album.editedAt) {
+ const filePath = path.join(__dirname, '../../../../', process.env.UPLOAD_FOLDER, 'zips', `${album.userId}-${album.id}.zip`);
+ const exists = await jetpack.existsAsync(filePath);
+ /*
+ Make sure the file exists just in case, and if not, continue to it's generation.
+ */
+ if (exists) {
+ const fileName = `${process.env.SERVICE_NAME}-${identifier}.zip`;
+ return res.download(filePath, fileName);
+ }
+ }
+
+ /*
+ Grab the files in a very unoptimized way. (This should be a join between both tables)
+ */
+ const fileList = await db.table('albumsFiles')
+ .where('albumId', link.albumId)
+ .select('fileId');
+
+ /*
+ If there are no files, stop here
+ */
+ if (!fileList || !fileList.length) return res.status(400).json({ message: 'Can\'t download an empty album' });
+
+ /*
+ Get the actual files
+ */
+ const fileIds = fileList.map(el => el.fileId);
+ const files = await db.table('files')
+ .whereIn('id', fileIds)
+ .select('name');
+ const filesToZip = files.map(el => el.name);
+
+ try {
+ Util.createZip(filesToZip, album);
+ await db.table('albums')
+ .where('id', link.albumId)
+ .update('zippedAt', db.fn.now());
+
+ const filePath = path.join(__dirname, '../../../../', process.env.UPLOAD_FOLDER, 'zips', `${album.userId}-${album.id}.zip`);
+ const fileName = `${process.env.SERVICE_NAME}-${identifier}.zip`;
+ return res.download(filePath, fileName);
+ } catch (error) {
+ log.error(error);
+ return res.status(500).json({ message: 'There was a problem downloading the album' });
+ }
+ }
+}
+
+module.exports = albumGET;
diff --git a/src/api/routes/albums/albumsGET.js b/src/api/routes/albums/albumsGET.js
new file mode 100644
index 0000000..3c18d8f
--- /dev/null
+++ b/src/api/routes/albums/albumsGET.js
@@ -0,0 +1,71 @@
+/* eslint-disable max-classes-per-file */
+const Route = require('../../structures/Route');
+const Util = require('../../utils/Util');
+
+class albumsGET extends Route {
+ constructor() {
+ super('/albums/mini', 'get');
+ }
+
+ async run(req, res, db, user) {
+ /*
+ Let's fetch the albums. This route will only return a small portion
+ of the album files for displaying on the dashboard. It's probably useless
+ for anyone consuming the API outside of the chibisafe frontend.
+ */
+ const albums = await db
+ .table('albums')
+ .where('albums.userId', user.id)
+ .select('id', 'name', 'nsfw', 'createdAt', 'editedAt')
+ .orderBy('createdAt', 'desc');
+
+ for (const album of albums) {
+ // Fetch the total amount of files each album has.
+ const fileCount = await db // eslint-disable-line no-await-in-loop
+ .table('albumsFiles')
+ .where('albumId', album.id)
+ .count({ count: 'id' });
+
+ // Fetch the file list from each album but limit it to 5 per album
+ const files = await db // eslint-disable-line no-await-in-loop
+ .table('albumsFiles')
+ .join('files', { 'files.id': 'albumsFiles.fileId' })
+ .where('albumId', album.id)
+ .select('files.id', 'files.name')
+ .orderBy('albumsFiles.id', 'desc')
+ .limit(5);
+
+ // Fetch thumbnails and stuff
+ for (let file of files) {
+ file = Util.constructFilePublicLink(file);
+ }
+
+ album.fileCount = fileCount[0].count;
+ album.files = files;
+ }
+
+ return res.json({
+ message: 'Successfully retrieved albums',
+ albums
+ });
+ }
+}
+
+class albumsDropdownGET extends Route {
+ constructor() {
+ super('/albums/dropdown', 'get', { canApiKey: true });
+ }
+
+ async run(req, res, db, user) {
+ const albums = await db
+ .table('albums')
+ .where('userId', user.id)
+ .select('id', 'name');
+ return res.json({
+ message: 'Successfully retrieved albums',
+ albums
+ });
+ }
+}
+
+module.exports = [albumsGET, albumsDropdownGET];
diff --git a/src/api/routes/albums/link/linkDELETE.js b/src/api/routes/albums/link/linkDELETE.js
new file mode 100644
index 0000000..1af704e
--- /dev/null
+++ b/src/api/routes/albums/link/linkDELETE.js
@@ -0,0 +1,35 @@
+const Route = require('../../../structures/Route');
+
+class linkDELETE extends Route {
+ constructor() {
+ super('/album/link/delete/:identifier', 'delete');
+ }
+
+ async run(req, res, db, user) {
+ const { identifier } = req.params;
+ if (!identifier) return res.status(400).json({ message: 'Invalid identifier supplied' });
+
+ try {
+ const link = await db.table('links')
+ .where({ identifier, userId: user.id })
+ .first();
+
+ if (!link) return res.status(400).json({ message: 'Identifier doesn\'t exist or doesnt\'t belong to the user' });
+
+ await db.table('links')
+ .where({ id: link.id })
+ .delete();
+ await db.table('albumsLinks')
+ .where({ linkId: link.id })
+ .delete();
+ } catch (error) {
+ return super.error(res, error);
+ }
+
+ return res.json({
+ message: 'Successfully deleted link'
+ });
+ }
+}
+
+module.exports = linkDELETE;
diff --git a/src/api/routes/albums/link/linkEditPOST.js b/src/api/routes/albums/link/linkEditPOST.js
new file mode 100644
index 0000000..97122a2
--- /dev/null
+++ b/src/api/routes/albums/link/linkEditPOST.js
@@ -0,0 +1,38 @@
+const Route = require('../../../structures/Route');
+
+class linkEditPOST extends Route {
+ constructor() {
+ super('/album/link/edit', 'post');
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { identifier, enableDownload, expiresAt } = req.body;
+ if (!identifier) return res.status(400).json({ message: 'Invalid album identifier supplied' });
+
+ /*
+ Make sure the link exists
+ */
+ const link = await db
+ .table('links')
+ .where({ identifier, userId: user.id })
+ .first();
+ if (!link) return res.status(400).json({ message: "The link doesn't exist or doesn't belong to the user" });
+
+ try {
+ const updateObj = {
+ enableDownload: enableDownload || false,
+ expiresAt // This one should be null if not supplied
+ };
+ await db
+ .table('links')
+ .where({ identifier })
+ .update(updateObj);
+ return res.json({ message: 'Editing the link was successful', data: updateObj });
+ } catch (error) {
+ return super.error(res, error);
+ }
+ }
+}
+
+module.exports = linkEditPOST;
diff --git a/src/api/routes/albums/link/linkPOST.js b/src/api/routes/albums/link/linkPOST.js
new file mode 100644
index 0000000..28e9dfe
--- /dev/null
+++ b/src/api/routes/albums/link/linkPOST.js
@@ -0,0 +1,78 @@
+const Route = require('../../../structures/Route');
+const Util = require('../../../utils/Util');
+
+class linkPOST extends Route {
+ constructor() {
+ super('/album/link/new', 'post');
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { albumId } = req.body;
+ if (!albumId) return res.status(400).json({ message: 'No album provided' });
+
+ /*
+ Make sure the album exists
+ */
+ const exists = await db
+ .table('albums')
+ .where({ id: albumId, userId: user.id })
+ .first();
+ if (!exists) return res.status(400).json({ message: 'Album doesn\t exist' });
+
+ /*
+ Count the amount of links created for that album already and error out if max was reached
+ */
+ const count = await db
+ .table('links')
+ .where('albumId', albumId)
+ .count({ count: 'id' })
+ .first();
+ if (count >= parseInt(process.env.MAX_LINKS_PER_ALBUM, 10)) return res.status(400).json({ message: 'Maximum links per album reached' });
+
+ let { identifier } = req.body;
+ if (identifier) {
+ if (!user.isAdmin) return res.status(401).json({ message: 'Only administrators can create custom links' });
+
+ if (!(/^[a-zA-Z0-9-_]+$/.test(identifier))) return res.status(400).json({ message: 'Only alphanumeric, dashes, and underscore characters are allowed' });
+
+ /*
+ Make sure that the id doesn't already exists in the database
+ */
+ const idExists = await db
+ .table('links')
+ .where({ identifier })
+ .first();
+
+ if (idExists) return res.status(400).json({ message: 'Album with this identifier already exists' });
+ } else {
+ /*
+ Try to allocate a new identifier in the database
+ */
+ identifier = await Util.getUniqueAlbumIdentifier();
+ if (!identifier) return res.status(500).json({ message: 'There was a problem allocating a link for your album' });
+ }
+
+ try {
+ const insertObj = {
+ identifier,
+ userId: user.id,
+ albumId,
+ enabled: true,
+ enableDownload: true,
+ expiresAt: null,
+ views: 0
+ };
+ await db.table('links').insert(insertObj);
+
+ return res.json({
+ message: 'The link was created successfully',
+ data: insertObj
+ });
+ } catch (error) {
+ return super.error(res, error);
+ }
+ }
+}
+
+module.exports = linkPOST;
diff --git a/src/api/routes/albums/link/linksGET.js b/src/api/routes/albums/link/linksGET.js
new file mode 100644
index 0000000..edab49a
--- /dev/null
+++ b/src/api/routes/albums/link/linksGET.js
@@ -0,0 +1,22 @@
+const Route = require('../../../structures/Route');
+
+class linkPOST extends Route {
+ constructor() {
+ super('/album/:id/links', 'get');
+ }
+
+ async run(req, res, db, user) {
+ const { id } = req.params;
+ if (!id) return res.status(400).json({ message: 'Invalid id supplied' });
+
+ const links = await db.table('links')
+ .where({ albumId: id, userId: user.id });
+
+ return res.json({
+ message: 'Successfully retrieved links',
+ links
+ });
+ }
+}
+
+module.exports = linkPOST;
diff --git a/src/api/routes/auth/loginPOST.js b/src/api/routes/auth/loginPOST.js
new file mode 100644
index 0000000..373252b
--- /dev/null
+++ b/src/api/routes/auth/loginPOST.js
@@ -0,0 +1,56 @@
+const bcrypt = require('bcrypt');
+const moment = require('moment');
+const JWT = require('jsonwebtoken');
+const Route = require('../../structures/Route');
+
+class loginPOST extends Route {
+ constructor() {
+ super('/auth/login', 'post', { bypassAuth: true });
+ }
+
+ async run(req, res, db) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { username, password } = req.body;
+ if (!username || !password) return res.status(401).json({ message: 'Invalid body provided' });
+
+ /*
+ Checks if the user exists
+ */
+ const user = await db.table('users').where('username', username).first();
+ if (!user) return res.status(401).json({ message: 'Invalid authorization' });
+
+ /*
+ Checks if the user is disabled
+ */
+ if (!user.enabled) return res.status(401).json({ message: 'This account has been disabled' });
+
+ /*
+ Checks if the password is right
+ */
+ const comparePassword = await bcrypt.compare(password, user.password);
+ if (!comparePassword) return res.status(401).json({ message: 'Invalid authorization.' });
+
+ /*
+ Create the jwt with some data
+ */
+ const jwt = JWT.sign({
+ iss: 'chibisafe',
+ sub: user.id,
+ iat: moment.utc().valueOf()
+ }, process.env.SECRET, { expiresIn: '30d' });
+
+ return res.json({
+ message: 'Successfully logged in.',
+ user: {
+ id: user.id,
+ username: user.username,
+ apiKey: user.apiKey,
+ isAdmin: user.isAdmin
+ },
+ token: jwt,
+ apiKey: user.apiKey
+ });
+ }
+}
+
+module.exports = loginPOST;
diff --git a/src/api/routes/auth/registerPOST.js b/src/api/routes/auth/registerPOST.js
new file mode 100644
index 0000000..1cf3630
--- /dev/null
+++ b/src/api/routes/auth/registerPOST.js
@@ -0,0 +1,59 @@
+const bcrypt = require('bcrypt');
+const moment = require('moment');
+const Route = require('../../structures/Route');
+const log = require('../../utils/Log');
+
+class registerPOST extends Route {
+ constructor() {
+ super('/auth/register', 'post', { bypassAuth: true });
+ }
+
+ async run(req, res, db) {
+ if (process.env.USER_ACCOUNTS === 'false') return res.status(401).json({ message: 'Creation of new accounts is currently disabled' });
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { username, password } = req.body;
+ if (!username || !password) return res.status(401).json({ message: 'Invalid body provided' });
+
+ if (username.length < 4 || username.length > 32) {
+ return res.status(400).json({ message: 'Username must have 4-32 characters' });
+ }
+ if (password.length < 6 || password.length > 64) {
+ return res.status(400).json({ message: 'Password must have 6-64 characters' });
+ }
+
+ /*
+ Make sure the username doesn't exist yet
+ */
+ const user = await db.table('users').where('username', username).first();
+ if (user) return res.status(401).json({ message: 'Username already exists' });
+
+ /*
+ Hash the supplied password
+ */
+ let hash;
+ try {
+ hash = await bcrypt.hash(password, 10);
+ } catch (error) {
+ log.error('Error generating password hash');
+ log.error(error);
+ return res.status(401).json({ message: 'There was a problem processing your account' });
+ }
+
+ /*
+ Create the user
+ */
+ const now = moment.utc().toDate();
+ await db.table('users').insert({
+ username,
+ password: hash,
+ passwordEditedAt: now,
+ createdAt: now,
+ editedAt: now,
+ enabled: true,
+ isAdmin: false
+ });
+ return res.json({ message: 'The account was created successfully' });
+ }
+}
+
+module.exports = registerPOST;
diff --git a/src/api/routes/files/albumAddPOST.js b/src/api/routes/files/albumAddPOST.js
new file mode 100644
index 0000000..7b8acf7
--- /dev/null
+++ b/src/api/routes/files/albumAddPOST.js
@@ -0,0 +1,33 @@
+const Route = require('../../structures/Route');
+
+class albumAddPOST extends Route {
+ constructor() {
+ super('/file/album/add', 'post');
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { fileId, albumId } = req.body;
+ if (!fileId || !albumId) return res.status(400).json({ message: 'No id provided' });
+
+ // Make sure both file and album belong to the user
+ const file = await db.table('files').where({ id: fileId, userId: user.id }).first();
+ if (!file) return res.status(400).json({ message: 'File doesn\'t exist.' });
+ const album = await db.table('albums').where({ id: albumId, userId: user.id }).first();
+ if (!album) return res.status(400).json({ message: 'Album doesn\'t exist.' });
+
+ try {
+ await db.table('albumsFiles')
+ .insert({ fileId, albumId });
+ } catch (error) {
+ return super.error(res, error);
+ }
+
+ return res.json({
+ message: 'Successfully added file to album',
+ data: { fileId, album: { id: album.id, name: album.name } }
+ });
+ }
+}
+
+module.exports = albumAddPOST;
diff --git a/src/api/routes/files/albumDelPOST.js b/src/api/routes/files/albumDelPOST.js
new file mode 100644
index 0000000..8304163
--- /dev/null
+++ b/src/api/routes/files/albumDelPOST.js
@@ -0,0 +1,34 @@
+const Route = require('../../structures/Route');
+
+class albumDelPOST extends Route {
+ constructor() {
+ super('/file/album/del', 'post');
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { fileId, albumId } = req.body;
+ if (!fileId || !albumId) return res.status(400).json({ message: 'No id provided' });
+
+ // Make sure both file and album belong to the user
+ const file = await db.table('files').where({ id: fileId, userId: user.id }).first();
+ if (!file) return res.status(400).json({ message: 'File doesn\'t exist.' });
+ const album = await db.table('albums').where({ id: albumId, userId: user.id }).first();
+ if (!album) return res.status(400).json({ message: 'Album doesn\'t exist.' });
+
+ try {
+ await db.table('albumsFiles')
+ .where({ fileId, albumId })
+ .delete();
+ } catch (error) {
+ return super.error(res, error);
+ }
+
+ return res.json({
+ message: 'Successfully removed file from album',
+ data: { fileId, album: { id: album.id, name: album.name } }
+ });
+ }
+}
+
+module.exports = albumDelPOST;
diff --git a/src/api/routes/files/fileDELETE.js b/src/api/routes/files/fileDELETE.js
new file mode 100644
index 0000000..e467601
--- /dev/null
+++ b/src/api/routes/files/fileDELETE.js
@@ -0,0 +1,33 @@
+const Route = require('../../structures/Route');
+const Util = require('../../utils/Util');
+const log = require('../../utils/Log');
+
+class fileDELETE extends Route {
+ constructor() {
+ super('/file/:id', 'delete', { canApiKey: true });
+ }
+
+ async run(req, res, db, user) {
+ const { id } = req.params;
+ if (!id) return res.status(400).json({ message: 'Invalid file ID supplied' });
+
+ /*
+ Make sure the file exists
+ */
+ const file = await db.table('files').where({ id, userId: user.id }).first();
+ if (!file) return res.status(400).json({ message: 'The file doesn\'t exist or doesn\'t belong to the user' });
+
+ /*
+ Delete the file
+ */
+ try {
+ await Util.deleteFile(file.name, true);
+ return res.json({ message: 'The file was deleted successfully' });
+ } catch (error) {
+ log.error(error);
+ return res.json({ message: 'There was a problem deleting the file' });
+ }
+ }
+}
+
+module.exports = fileDELETE;
diff --git a/src/api/routes/files/fileGET.js b/src/api/routes/files/fileGET.js
new file mode 100644
index 0000000..9ec6f22
--- /dev/null
+++ b/src/api/routes/files/fileGET.js
@@ -0,0 +1,46 @@
+const Route = require('../../structures/Route');
+const Util = require('../../utils/Util');
+
+class fileGET extends Route {
+ constructor() {
+ super('/file/:id', 'get');
+ }
+
+ async run(req, res, db, user) {
+ const { id } = req.params;
+ if (!id) return res.status(400).json({ message: 'Invalid file ID supplied' });
+
+ /*
+ Make sure the file exists
+ */
+ let file = await db.table('files').where({ id, userId: user.id }).first();
+ if (!file) return res.status(400).json({ message: 'The file doesn\'t exist or doesn\'t belong to the user' });
+
+ file = Util.constructFilePublicLink(file);
+
+ /*
+ Fetch the albums
+ */
+ const albums = await db.table('albumsFiles')
+ .where('fileId', id)
+ .join('albums', 'albums.id', 'albumsFiles.albumId')
+ .select('albums.id', 'albums.name');
+
+ /*
+ Fetch the tags
+ */
+ const tags = await db.table('fileTags')
+ .where('fileId', id)
+ .join('tags', 'tags.id', 'fileTags.tagId')
+ .select('tags.id', 'tags.uuid', 'tags.name');
+
+ return res.json({
+ message: 'Successfully retrieved file',
+ file,
+ albums,
+ tags
+ });
+ }
+}
+
+module.exports = fileGET;
diff --git a/src/api/routes/files/filesAlbumsGET.js b/src/api/routes/files/filesAlbumsGET.js
new file mode 100644
index 0000000..7f1190c
--- /dev/null
+++ b/src/api/routes/files/filesAlbumsGET.js
@@ -0,0 +1,34 @@
+const Route = require('../../structures/Route');
+
+class filesGET extends Route {
+ constructor() {
+ super('/file/:id/albums', 'get');
+ }
+
+ async run(req, res, db, user) {
+ const { id } = req.params;
+ if (!id) return res.status(400).json({ message: 'Invalid file ID supplied' });
+
+ const file = await db.table('files').where({ id, userId: user.id }).first();
+ if (!file) return res.status(400).json({ message: 'The file doesn\'t exist or doesn\'t belong to the user' });
+
+ let albums = [];
+ let albumFiles = await db.table('albumsFiles')
+ .where('fileId', id)
+ .select('albumId');
+
+ if (albumFiles.length) {
+ albumFiles = albumFiles.map(a => a.albumId);
+ albums = await db.table('albums')
+ .whereIn('id', albumFiles)
+ .select('id', 'name');
+ }
+
+ return res.json({
+ message: 'Successfully retrieved file albums',
+ albums
+ });
+ }
+}
+
+module.exports = filesGET;
diff --git a/src/api/routes/files/filesGET.js b/src/api/routes/files/filesGET.js
new file mode 100644
index 0000000..9e90633
--- /dev/null
+++ b/src/api/routes/files/filesGET.js
@@ -0,0 +1,44 @@
+const Route = require('../../structures/Route');
+const Util = require('../../utils/Util');
+
+class filesGET extends Route {
+ constructor() {
+ super('/files', 'get');
+ }
+
+ async run(req, res, db, user) {
+ let count = 0;
+
+ let files = db.table('files')
+ .where({ userId: user.id })
+ .orderBy('createdAt', 'desc');
+
+ const { page, limit = 100 } = req.query;
+ if (page && page >= 0) {
+ files = await files.offset((page - 1) * limit).limit(limit);
+
+ const dbRes = await db.table('files')
+ .count('* as count')
+ .where({ userId: user.id })
+ .first();
+
+ count = dbRes.count;
+ } else {
+ files = await files; // execute the query
+ count = files.length;
+ }
+
+ // For each file, create the public link to be able to display the file
+ for (let file of files) {
+ file = Util.constructFilePublicLink(file);
+ }
+
+ return res.json({
+ message: 'Successfully retrieved files',
+ files,
+ count
+ });
+ }
+}
+
+module.exports = filesGET;
diff --git a/src/api/routes/files/tagAddBatchPOST.js b/src/api/routes/files/tagAddBatchPOST.js
new file mode 100644
index 0000000..679945d
--- /dev/null
+++ b/src/api/routes/files/tagAddBatchPOST.js
@@ -0,0 +1,40 @@
+const Route = require('../../structures/Route');
+
+class tagAddBatchPOST extends Route {
+ constructor() {
+ super('/file/tag/addBatch', 'post');
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { fileId, tagNames } = req.body;
+ if (!fileId || !tagNames.length) return res.status(400).json({ message: 'No tags provided' });
+
+ // Make sure the file belongs to the user
+ const file = await db.table('files').where({ id: fileId, userId: user.id }).first();
+ if (!file) return res.status(400).json({ message: 'File doesn\'t exist.' });
+
+ const errors = {};
+ const addedTags = [];
+ for await (const tagName of tagNames) {
+ try {
+ const tag = await db.table('tags').where({ name: tagName, userId: user.id }).first();
+ if (!tag) throw new Error('Tag doesn\'t exist in the database');
+ await db.table('fileTags').insert({ fileId, tagId: tag.id });
+
+ addedTags.push(tag);
+ } catch (e) {
+ errors[tagName] = e.message;
+ }
+ }
+
+ return res.json({
+ message: 'Successfully added tags to file',
+ data: { fileId, tags: addedTags },
+ errors
+ });
+ // eslint-disable-next-line consistent-return
+ }
+}
+
+module.exports = tagAddBatchPOST;
diff --git a/src/api/routes/files/tagAddPOST.js b/src/api/routes/files/tagAddPOST.js
new file mode 100644
index 0000000..2bbfa07
--- /dev/null
+++ b/src/api/routes/files/tagAddPOST.js
@@ -0,0 +1,36 @@
+const Route = require('../../structures/Route');
+
+class tagAddPOST extends Route {
+ constructor() {
+ super('/file/tag/add', 'post');
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+
+ const { fileId, tagName } = req.body;
+ if (!fileId || !tagName.length) return res.status(400).json({ message: 'No tag provided' });
+
+ // Make sure the file belongs to the user
+ const file = await db.table('files').where({ id: fileId, userId: user.id }).first();
+ if (!file) return res.status(400).json({ message: 'File doesn\'t exist.' });
+
+ // Make sure user has a tag like that
+ const tag = await db.table('tags').where({ name: tagName, userId: user.id }).first();
+ if (!tag) return res.status(400).json({ message: 'Tag doesn\'t exist. ' });
+
+ try {
+ await db.table('fileTags').insert({ fileId, tagId: tag.id });
+ } catch (error) {
+ return super.error(res, error);
+ }
+
+ return res.json({
+ message: 'Successfully added tag to file',
+ data: { fileId, tag }
+ });
+ // eslint-disable-next-line consistent-return
+ }
+}
+
+module.exports = tagAddPOST;
diff --git a/src/api/routes/files/tagDelPOST.js b/src/api/routes/files/tagDelPOST.js
new file mode 100644
index 0000000..ac0bfe4
--- /dev/null
+++ b/src/api/routes/files/tagDelPOST.js
@@ -0,0 +1,38 @@
+const Route = require('../../structures/Route');
+
+class tagDelPost extends Route {
+ constructor() {
+ super('/file/tag/del', 'post');
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+
+ const { fileId, tagName } = req.body;
+ if (!fileId || !tagName.length) return res.status(400).json({ message: 'No tag provided' });
+
+ // Make sure the file belongs to the user
+ const file = await db.table('files').where({ id: fileId, userId: user.id }).first();
+ if (!file) return res.status(400).json({ message: 'File doesn\'t exist.' });
+
+ // Make sure user has a tag like that
+ const tag = await db.table('tags').where({ name: tagName, userId: user.id }).first();
+ if (!tag) return res.status(400).json({ message: 'Tag doesn\'t exist. ' });
+
+ try {
+ await db.table('fileTags')
+ .where({ fileId, tagId: tag.id })
+ .delete();
+ } catch (error) {
+ return super.error(res, error);
+ }
+
+ return res.json({
+ message: 'Successfully removed tag from file',
+ data: { fileId, tag }
+ });
+ // eslint-disable-next-line consistent-return
+ }
+}
+
+module.exports = tagDelPost;
diff --git a/src/api/routes/search/searchGET.js b/src/api/routes/search/searchGET.js
new file mode 100644
index 0000000..40107d8
--- /dev/null
+++ b/src/api/routes/search/searchGET.js
@@ -0,0 +1,63 @@
+const searchQuery = require('search-query-parser');
+
+const Route = require('../../structures/Route');
+const Util = require('../../utils/Util');
+
+const queryHelper = require('../../utils/QueryHelper');
+
+const options = {
+ keywords: ['album', 'tag', 'before', 'after', 'file'],
+ offsets: false,
+ alwaysArray: true,
+ tokenize: true
+};
+
+class configGET extends Route {
+ constructor() {
+ super('/search/', 'get');
+ }
+
+ async run(req, res, db, user) {
+ let count = 0;
+
+ const { q } = req.query;
+ const parsed = searchQuery.parse(q, options);
+
+ let files = db.table('files')
+ .select('*')
+ .where({ 'files.userId': user.id })
+ .orderBy('files.createdAt', 'desc');
+
+ files = queryHelper.processQuery(db, files, parsed);
+
+ const query = files.toString();
+ const { page, limit = 100 } = req.query;
+
+ if (page && page >= 0) {
+ let dbRes = files.clone(); // clone the query to attach a count to it later on
+ files = await files.offset((page - 1) * limit).limit(limit);
+
+ dbRes = await dbRes.count('* as count').first();
+
+ count = dbRes.count;
+ } else {
+ files = await files; // execute the query
+ count = files.length;
+ }
+
+ // For each file, create the public link to be able to display the file
+ for (let file of files) {
+ file = Util.constructFilePublicLink(file);
+ }
+
+ return res.json({
+ message: 'Successfully retrieved files',
+ query,
+ parsed,
+ files,
+ count
+ });
+ }
+}
+
+module.exports = configGET;
diff --git a/src/api/routes/service/configGET.js b/src/api/routes/service/configGET.js
new file mode 100644
index 0000000..bc91a7e
--- /dev/null
+++ b/src/api/routes/service/configGET.js
@@ -0,0 +1,27 @@
+const Route = require('../../structures/Route');
+
+class configGET extends Route {
+ constructor() {
+ super('/service/config', 'get', { adminOnly: true });
+ }
+
+ run(req, res) {
+ return res.json({
+ message: 'Successfully retrieved config',
+ config: {
+ serviceName: process.env.SERVICE_NAME,
+ uploadFolder: process.env.UPLOAD_FOLDER,
+ linksPerAlbum: parseInt(process.env.MAX_LINKS_PER_ALBUM, 10),
+ maxUploadSize: parseInt(process.env.MAX_SIZE, 10),
+ filenameLength: parseInt(process.env.GENERATED_FILENAME_LENGTH, 10),
+ albumLinkLength: parseInt(process.env.GENERATED_ALBUM_LENGTH, 10),
+ generateThumbnails: process.env.GENERATE_THUMBNAILS === 'true',
+ generateZips: process.env.GENERATE_ZIPS === 'true',
+ publicMode: process.env.PUBLIC_MODE === 'true',
+ enableAccounts: process.env.USER_ACCOUNTS === 'true'
+ }
+ });
+ }
+}
+
+module.exports = configGET;
diff --git a/src/api/routes/service/restartPOST.js b/src/api/routes/service/restartPOST.js
new file mode 100644
index 0000000..530cc91
--- /dev/null
+++ b/src/api/routes/service/restartPOST.js
@@ -0,0 +1,14 @@
+const Route = require('../../structures/Route');
+
+class restartPOST extends Route {
+ constructor() {
+ super('/service/restart', 'post', { adminOnly: true });
+ }
+
+ run(req, res) {
+ res.json({ message: 'Restarting...' });
+ process.exit(0);
+ }
+}
+
+module.exports = restartPOST;
diff --git a/src/api/routes/service/versionGET.js b/src/api/routes/service/versionGET.js
new file mode 100644
index 0000000..dfb994a
--- /dev/null
+++ b/src/api/routes/service/versionGET.js
@@ -0,0 +1,15 @@
+const Route = require('../../structures/Route');
+
+class versionGET extends Route {
+ constructor() {
+ super('/version', 'get', { bypassAuth: true });
+ }
+
+ run(req, res) {
+ return res.json({
+ version: process.env.npm_package_version
+ });
+ }
+}
+
+module.exports = versionGET;
diff --git a/src/api/routes/tags/tagDELETE.js b/src/api/routes/tags/tagDELETE.js
new file mode 100644
index 0000000..cf74029
--- /dev/null
+++ b/src/api/routes/tags/tagDELETE.js
@@ -0,0 +1,37 @@
+const Route = require('../../structures/Route');
+const Util = require('../../utils/Util');
+
+class tagDELETE extends Route {
+ constructor() {
+ super('/tag/:id/:purge*?', 'delete');
+ }
+
+ async run(req, res, db, user) {
+ const { id, purge } = req.params;
+ if (!id) return res.status(400).json({ message: 'Invalid tag supplied' });
+
+ /*
+ Check if the tag exists
+ */
+ const tag = await db.table('tags').where({ id, userId: user.id }).first();
+ if (!tag) return res.status(400).json({ message: 'The tag doesn\'t exist or doesn\'t belong to the user' });
+
+ try {
+ /*
+ Should we also delete every file of that tag?
+ */
+ if (purge) {
+ await Util.deleteAllFilesFromTag(id);
+ }
+ /*
+ Delete the tag
+ */
+ await db.table('tags').where({ id }).delete();
+ return res.json({ message: 'The tag was deleted successfully', data: tag });
+ } catch (error) {
+ return super.error(res, error);
+ }
+ }
+}
+
+module.exports = tagDELETE;
diff --git a/src/api/routes/tags/tagPOST.js b/src/api/routes/tags/tagPOST.js
new file mode 100644
index 0000000..89b296d
--- /dev/null
+++ b/src/api/routes/tags/tagPOST.js
@@ -0,0 +1,36 @@
+const moment = require('moment');
+const Route = require('../../structures/Route');
+
+class tagPOST extends Route {
+ constructor() {
+ super('/tag/new', 'post');
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { name } = req.body;
+ if (!name) return res.status(400).json({ message: 'No name provided' });
+
+ /*
+ Check that a tag with that name doesn't exist yet
+ */
+ const tag = await db.table('tags').where({ name, userId: user.id }).first();
+ if (tag) return res.status(401).json({ message: 'There\'s already a tag with that name' });
+
+ const now = moment.utc().toDate();
+ const insertObj = {
+ name,
+ userId: user.id,
+ createdAt: now,
+ editedAt: now
+ };
+
+ const dbRes = await db.table('tags').insert(insertObj);
+
+ insertObj.id = dbRes.pop();
+
+ return res.json({ message: 'The tag was created successfully', data: insertObj });
+ }
+}
+
+module.exports = tagPOST;
diff --git a/src/api/routes/tags/tagsGET.js b/src/api/routes/tags/tagsGET.js
new file mode 100644
index 0000000..329d789
--- /dev/null
+++ b/src/api/routes/tags/tagsGET.js
@@ -0,0 +1,30 @@
+const Route = require('../../structures/Route');
+
+class tagsGET extends Route {
+ constructor() {
+ super('/tags', 'get');
+ }
+
+ async run(req, res, db, user) {
+ try {
+ const tags = await db.table('tags')
+ .where('userId', user.id);
+
+ for (const tag of tags) {
+ const files = await db.table('fileTags')
+ .where({ tagId: tag.id });
+
+ tag.count = files.length ? files.length : 0;
+ }
+
+ return res.json({
+ message: 'Successfully retrieved tags',
+ tags
+ });
+ } catch (error) {
+ return super.error(res, error);
+ }
+ }
+}
+
+module.exports = tagsGET;
diff --git a/src/api/routes/uploads/chunksPOST.js b/src/api/routes/uploads/chunksPOST.js
new file mode 100644
index 0000000..9cf7338
--- /dev/null
+++ b/src/api/routes/uploads/chunksPOST.js
@@ -0,0 +1,99 @@
+const path = require('path');
+const jetpack = require('fs-jetpack');
+const randomstring = require('randomstring');
+const Util = require('../../utils/Util');
+const Route = require('../../structures/Route');
+
+class uploadPOST extends Route {
+ constructor() {
+ super('/upload/chunks', 'post', {
+ bypassAuth: true,
+ canApiKey: true
+ });
+ }
+
+ async run(req, res, db) {
+ const user = await Util.isAuthorized(req);
+ if (!user && process.env.PUBLIC_MODE === 'false') return res.status(401).json({ message: 'Not authorized to use this resource' });
+
+ const filename = Util.getUniqueFilename(randomstring.generate(32));
+ // console.log('Files', req.body.files);
+ const info = {
+ size: req.body.files[0].size,
+ url: `${process.env.DOMAIN}/`
+ };
+
+ for (const chunk of req.body.files) {
+ const { uuid } = chunk;
+ // console.log('Chunk', chunk);
+
+ const chunkOutput = path.join(__dirname,
+ '../../../../',
+ process.env.UPLOAD_FOLDER,
+ 'chunks',
+ uuid);
+ const chunkDir = await jetpack.list(chunkOutput);
+ const ext = path.extname(chunkDir[0]);
+ const output = path.join(__dirname,
+ '../../../../',
+ process.env.UPLOAD_FOLDER,
+ `${filename}${ext || ''}`);
+ chunkDir.sort();
+
+ // Save some data
+ info.name = `${filename}${ext || ''}`;
+ info.url += `${filename}${ext || ''}`;
+ info.data = chunk;
+
+ for (let i = 0; i < chunkDir.length; i++) {
+ const dir = path.join(__dirname,
+ '../../../../',
+ process.env.UPLOAD_FOLDER,
+ 'chunks',
+ uuid,
+ chunkDir[i]);
+ const file = await jetpack.readAsync(dir, 'buffer');
+ await jetpack.appendAsync(output, file);
+ }
+ await jetpack.removeAsync(chunkOutput);
+ }
+
+ /*
+ If a file with the same hash and user is found, delete this
+ uploaded copy and return a link to the original
+ */
+ info.hash = await Util.getFileHash(info.name);
+ let existingFile = await Util.checkIfFileExists(db, user, info.hash);
+ if (existingFile) {
+ existingFile = Util.constructFilePublicLink(existingFile);
+ res.json({
+ message: 'Successfully uploaded the file.',
+ name: existingFile.name,
+ hash: existingFile.hash,
+ size: existingFile.size,
+ url: `${process.env.DOMAIN}/${existingFile.name}`,
+ deleteUrl: `${process.env.DOMAIN}/api/file/${existingFile.id}`,
+ repeated: true
+ });
+
+ return Util.deleteFile(info.name);
+ }
+
+ // Otherwise generate thumbs and do the rest
+ Util.generateThumbnails(info.name);
+ const insertedId = await Util.saveFileToDatabase(req, res, user, db, info, {
+ originalname: info.data.original, mimetype: info.data.type
+ });
+ if (!insertedId) return res.status(500).json({ message: 'There was an error saving the file.' });
+ info.deleteUrl = `${process.env.DOMAIN}/api/file/${insertedId[0]}`;
+ Util.saveFileToAlbum(db, req.headers.albumid, insertedId);
+ delete info.chunk;
+
+ return res.status(201).send({
+ message: 'Sucessfully merged the chunk(s).',
+ ...info
+ });
+ }
+}
+
+module.exports = uploadPOST;
diff --git a/src/api/routes/uploads/uploadPOST.js b/src/api/routes/uploads/uploadPOST.js
new file mode 100644
index 0000000..449999e
--- /dev/null
+++ b/src/api/routes/uploads/uploadPOST.js
@@ -0,0 +1,156 @@
+const path = require('path');
+const jetpack = require('fs-jetpack');
+const multer = require('multer');
+const moment = require('moment');
+const Util = require('../../utils/Util');
+const Route = require('../../structures/Route');
+
+const upload = multer({
+ storage: multer.memoryStorage(),
+ limits: {
+ fileSize: parseInt(process.env.MAX_SIZE, 10) * (1000 * 1000),
+ files: 1
+ },
+ fileFilter: (req, file, cb) =>
+ // TODO: Enable blacklisting of files/extensions
+ /*
+ if (options.blacklist.mimes.includes(file.mimetype)) {
+ return cb(new Error(`${file.mimetype} is a blacklisted filetype.`));
+ } else if (options.blacklist.extensions.some(ext => path.extname(file.originalname).toLowerCase() === ext)) {
+ return cb(new Error(`${path.extname(file.originalname).toLowerCase()} is a blacklisted extension.`));
+ }
+ */
+ cb(null, true)
+
+}).array('files[]');
+
+/*
+ TODO: If source has transparency generate a png thumbnail, otherwise a jpg.
+ TODO: If source is a gif, generate a thumb of the first frame and play the gif on hover on the frontend.
+
+ TODO: Think if its worth making a folder with the user uuid in uploads/ and upload the pictures there so
+ that this way at least not every single file will be in 1 directory
+
+ XXX: Now that the default behaviour is to serve files with node, we can actually pull this off.
+ Before this, having files in subfolders meant messing with nginx and the paths,
+ but now it should be fairly easy to re-arrange the folder structure with express.static
+ I see great value in this, open to suggestions.
+*/
+
+class uploadPOST extends Route {
+ constructor() {
+ super('/upload', 'post', {
+ bypassAuth: true,
+ canApiKey: true
+ });
+ }
+
+ async run(req, res, db) {
+ const user = await Util.isAuthorized(req);
+ if (!user && process.env.PUBLIC_MODE === 'false') return res.status(401).json({ message: 'Not authorized to use this resource' });
+
+ const albumId = req.body.albumid || req.headers.albumid;
+ if (albumId && !user) return res.status(401).json({ message: 'Only registered users can upload files to an album' });
+ if (albumId && user) {
+ const album = await db.table('albums').where({ id: albumId, userId: user.id }).first();
+ if (!album) return res.status(401).json({ message: 'Album doesn\'t exist or it doesn\'t belong to the user' });
+ }
+
+ return upload(req, res, async err => {
+ if (err) console.error(err.message);
+
+ let uploadedFile = {};
+ let insertedId;
+
+ // eslint-disable-next-line no-underscore-dangle
+ const remappedKeys = this._remapKeys(req.body);
+ const file = req.files[0];
+
+ const ext = path.extname(file.originalname);
+ const hash = Util.generateFileHash(file.buffer);
+
+ const filename = Util.getUniqueFilename(file.originalname);
+
+ /*
+ First let's get the hash of the file. This will be useful to check if the file
+ has already been upload by either the user or an anonymous user.
+ In case this is true, instead of uploading it again we retrieve the url
+ of the file that is already saved and thus don't store extra copies of the same file.
+
+ For this we need to wait until we have a filename so that we can delete the uploaded file.
+ */
+ const exists = await Util.checkIfFileExists(db, user, hash);
+ if (exists) return this.fileExists(res, exists, filename);
+
+ if (remappedKeys && remappedKeys.uuid) {
+ const chunkOutput = path.join(__dirname,
+ '../../../../',
+ process.env.UPLOAD_FOLDER,
+ 'chunks',
+ remappedKeys.uuid,
+ `${remappedKeys.chunkindex.padStart(3, 0)}${ext || ''}`);
+ await jetpack.writeAsync(chunkOutput, file.buffer);
+ } else {
+ const output = path.join(__dirname,
+ '../../../../',
+ process.env.UPLOAD_FOLDER,
+ filename);
+ await jetpack.writeAsync(output, file.buffer);
+ uploadedFile = {
+ name: filename,
+ hash,
+ size: file.buffer.length,
+ url: filename
+ };
+ }
+
+ if (!remappedKeys || !remappedKeys.uuid) {
+ Util.generateThumbnails(uploadedFile.name);
+ insertedId = await Util.saveFileToDatabase(req, res, user, db, uploadedFile, file);
+ if (!insertedId) return res.status(500).json({ message: 'There was an error saving the file.' });
+ uploadedFile.deleteUrl = `${process.env.DOMAIN}/api/file/${insertedId[0]}`;
+
+ /*
+ If the upload had an album specified we make sure to create the relation
+ and update the according timestamps..
+ */
+ Util.saveFileToAlbum(db, albumId, insertedId);
+ }
+
+ uploadedFile = Util.constructFilePublicLink(uploadedFile);
+ return res.status(201).send({
+ message: 'Sucessfully uploaded the file.',
+ ...uploadedFile
+ });
+ });
+ }
+
+ fileExists(res, exists, filename) {
+ exists = Util.constructFilePublicLink(exists);
+ res.json({
+ message: 'Successfully uploaded the file.',
+ name: exists.name,
+ hash: exists.hash,
+ size: exists.size,
+ url: `${process.env.DOMAIN}/${exists.name}`,
+ deleteUrl: `${process.env.DOMAIN}/api/file/${exists.id}`,
+ repeated: true
+ });
+
+ return Util.deleteFile(filename);
+ }
+
+ _remapKeys(body) {
+ const keys = Object.keys(body);
+ if (keys.length) {
+ for (const key of keys) {
+ if (!/^dz/.test(key)) continue;
+ body[key.replace(/^dz/, '')] = body[key];
+ delete body[key];
+ }
+ return body;
+ }
+ }
+}
+
+module.exports = uploadPOST;
diff --git a/src/api/routes/user/apiKey.js b/src/api/routes/user/apiKey.js
new file mode 100644
index 0000000..653c56a
--- /dev/null
+++ b/src/api/routes/user/apiKey.js
@@ -0,0 +1,34 @@
+const randomstring = require('randomstring');
+const moment = require('moment');
+const { dump } = require('dumper.js');
+const Route = require('../../structures/Route');
+
+class apiKeyPOST extends Route {
+ constructor() {
+ super('/user/apikey/change', 'post');
+ }
+
+ async run(req, res, db, user) {
+ const now = moment.utc().toDate();
+ const apiKey = randomstring.generate(64);
+
+ try {
+ await db.table('users')
+ .where({ id: user.id })
+ .update({
+ apiKey,
+ apiKeyEditedAt: now
+ });
+ } catch (error) {
+ dump(error);
+ return res.status(401).json({ message: 'There was a problem processing your account' });
+ }
+
+ return res.json({
+ message: 'Successfully created new api key',
+ apiKey
+ });
+ }
+}
+
+module.exports = apiKeyPOST;
diff --git a/src/api/routes/user/changePasswordPOST.js b/src/api/routes/user/changePasswordPOST.js
new file mode 100644
index 0000000..82bce40
--- /dev/null
+++ b/src/api/routes/user/changePasswordPOST.js
@@ -0,0 +1,46 @@
+const bcrypt = require('bcrypt');
+const moment = require('moment');
+const Route = require('../../structures/Route');
+const log = require('../../utils/Log');
+
+class changePasswordPOST extends Route {
+ constructor() {
+ super('/user/password/change', 'post');
+ }
+
+ async run(req, res, db, user) {
+ if (!req.body) return res.status(400).json({ message: 'No body provided' });
+ const { password, newPassword } = req.body;
+ if (!password || !newPassword) return res.status(401).json({ message: 'Invalid body provided' });
+ if (password === newPassword) return res.status(400).json({ message: 'Passwords have to be different' });
+
+ /*
+ Checks if the password is right
+ */
+ const comparePassword = await bcrypt.compare(password, user.password);
+ if (!comparePassword) return res.status(401).json({ message: 'Current password is incorrect' });
+
+ if (newPassword.length < 6 || newPassword.length > 64) {
+ return res.status(400).json({ message: 'Password must have 6-64 characters' });
+ }
+
+ let hash;
+ try {
+ hash = await bcrypt.hash(newPassword, 10);
+ } catch (error) {
+ log.error('Error generating password hash');
+ log.error(error);
+ return res.status(401).json({ message: 'There was a problem processing your account' });
+ }
+
+ const now = moment.utc().toDate();
+ await db.table('users').where('id', user.id).update({
+ password: hash,
+ passwordEditedAt: now
+ });
+
+ return res.json({ message: 'The password was changed successfully' });
+ }
+}
+
+module.exports = changePasswordPOST;
diff --git a/src/api/routes/user/userGET.js b/src/api/routes/user/userGET.js
new file mode 100644
index 0000000..7929aac
--- /dev/null
+++ b/src/api/routes/user/userGET.js
@@ -0,0 +1,21 @@
+const Route = require('../../structures/Route');
+
+class usersGET extends Route {
+ constructor() {
+ super('/users/me', 'get');
+ }
+
+ run(req, res, db, user) {
+ return res.json({
+ message: 'Successfully retrieved user',
+ user: {
+ id: user.id,
+ username: user.username,
+ isAdmin: user.isAdmin,
+ apiKey: user.apiKey
+ }
+ });
+ }
+}
+
+module.exports = usersGET;
diff --git a/src/api/routes/verifyGET.js b/src/api/routes/verifyGET.js
new file mode 100644
index 0000000..2f370e8
--- /dev/null
+++ b/src/api/routes/verifyGET.js
@@ -0,0 +1,20 @@
+const Route = require('../structures/Route');
+
+class verifyGET extends Route {
+ constructor() {
+ super('/verify', 'get');
+ }
+
+ run(req, res, db, user) {
+ return res.json({
+ message: 'Successfully verified token',
+ user: {
+ id: user.id,
+ username: user.username,
+ isAdmin: user.isAdmin
+ }
+ });
+ }
+}
+
+module.exports = verifyGET;
diff --git a/src/api/structures/Route.js b/src/api/structures/Route.js
new file mode 100644
index 0000000..bb7ba87
--- /dev/null
+++ b/src/api/structures/Route.js
@@ -0,0 +1,110 @@
+const nodePath = require('path');
+const JWT = require('jsonwebtoken');
+const db = require('knex')({
+ client: process.env.DB_CLIENT,
+ connection: {
+ host: process.env.DB_HOST,
+ user: process.env.DB_USER,
+ password: process.env.DB_PASSWORD,
+ database: process.env.DB_DATABASE,
+ filename: nodePath.join(__dirname, '../../../database/database.sqlite')
+ },
+ postProcessResponse: result => {
+ /*
+ Fun fact: Depending on the database used by the user and given that I don't want
+ to force a specific database for everyone because of the nature of this project,
+ some things like different data types for booleans need to be considered like in
+ the implementation below where sqlite returns 1 and 0 instead of true and false.
+ */
+ const booleanFields = ['enabled', 'enableDownload', 'isAdmin', 'nsfw'];
+
+ const processResponse = row => {
+ Object.keys(row).forEach(key => {
+ if (booleanFields.includes(key)) {
+ if (row[key] === 0) row[key] = false;
+ else if (row[key] === 1) row[key] = true;
+ }
+ });
+ return row;
+ };
+
+ if (Array.isArray(result)) return result.map(row => processResponse(row));
+ if (typeof result === 'object') return processResponse(result);
+ return result;
+ },
+ useNullAsDefault: process.env.DB_CLIENT === 'sqlite3'
+});
+const moment = require('moment');
+const log = require('../utils/Log');
+
+class Route {
+ constructor(path, method, options) {
+ if (!path) throw new Error('Every route needs a URL associated with it.');
+ if (!method) throw new Error('Every route needs its method specified.');
+
+ this.path = path;
+ this.method = method;
+ this.options = options || {};
+ }
+
+ async authorize(req, res) {
+ const banned = await db
+ .table('bans')
+ .where({ ip: req.ip })
+ .first();
+ if (banned) return res.status(401).json({ message: 'This IP has been banned from using the service.' });
+
+ if (this.options.bypassAuth) return this.run(req, res, db);
+ // The only reason I call it token here and not Api Key is to be backwards compatible
+ // with the uploader and sharex
+ // Small price to pay.
+ if (req.headers.token) return this.authorizeApiKey(req, res, req.headers.token);
+ if (!req.headers.authorization) return res.status(401).json({ message: 'No authorization header provided' });
+
+ const token = req.headers.authorization.split(' ')[1];
+ if (!token) return res.status(401).json({ message: 'No authorization header provided' });
+
+ return JWT.verify(token, process.env.SECRET, async (error, decoded) => {
+ if (error) {
+ log.error(error);
+ return res.status(401).json({ message: 'Invalid token' });
+ }
+ const id = decoded ? decoded.sub : '';
+ const iat = decoded ? decoded.iat : '';
+
+ const user = await db
+ .table('users')
+ .where({ id })
+ .first();
+ if (!user) return res.status(401).json({ message: 'Invalid authorization' });
+ if (iat && iat < moment(user.passwordEditedAt).format('x')) {
+ return res.status(401).json({ message: 'Token expired' });
+ }
+ if (!user.enabled) return res.status(401).json({ message: 'This account has been disabled' });
+ if (this.options.adminOnly && !user.isAdmin) { return res.status(401).json({ message: 'Invalid authorization' }); }
+
+ return this.run(req, res, db, user);
+ });
+ }
+
+ async authorizeApiKey(req, res, apiKey) {
+ if (!this.options.canApiKey) return res.status(401).json({ message: 'Api Key not allowed for this resource' });
+ const user = await db
+ .table('users')
+ .where({ apiKey })
+ .first();
+ if (!user) return res.status(401).json({ message: 'Invalid authorization' });
+ if (!user.enabled) return res.status(401).json({ message: 'This account has been disabled' });
+
+ return this.run(req, res, db, user);
+ }
+
+ run() {}
+
+ error(res, error) {
+ log.error(error);
+ return res.status(500).json({ message: 'There was a problem parsing the request' });
+ }
+}
+
+module.exports = Route;
diff --git a/src/api/structures/Server.js b/src/api/structures/Server.js
new file mode 100644
index 0000000..b8952a9
--- /dev/null
+++ b/src/api/structures/Server.js
@@ -0,0 +1,111 @@
+require('dotenv').config();
+
+if (!process.env.SERVER_PORT) {
+ console.log('Run the setup script first or fill the .env file manually before starting');
+ process.exit(0);
+}
+
+const express = require('express');
+const helmet = require('helmet');
+const cors = require('cors');
+const RateLimit = require('express-rate-limit');
+const bodyParser = require('body-parser');
+const jetpack = require('fs-jetpack');
+const path = require('path');
+const morgan = require('morgan');
+const rfs = require('rotating-file-stream');
+const log = require('../utils/Log');
+
+// eslint-disable-next-line no-unused-vars
+const rateLimiter = new RateLimit({
+ windowMs: parseInt(process.env.RATE_LIMIT_WINDOW, 10),
+ max: parseInt(process.env.RATE_LIMIT_MAX, 10),
+ delayMs: 0
+});
+
+class Server {
+ constructor() {
+ this.port = parseInt(process.env.SERVER_PORT, 10);
+ this.server = express();
+ this.server.set('trust proxy', 1);
+ this.server.use(helmet());
+ this.server.use(cors({ allowedHeaders: ['Accept', 'Authorization', 'Cache-Control', 'X-Requested-With', 'Content-Type', 'albumId'] }));
+ this.server.use((req, res, next) => {
+ // This bypasses the headers.accept for album download, since it's accesed directly through the browser.
+ if ((req.url.includes('/api/album/') || req.url.includes('/zip')) && req.method === 'GET') return next();
+ // This bypasses the headers.accept if we are accessing the frontend
+ if (!req.url.includes('/api/') && req.method === 'GET') return next();
+ if (req.headers.accept && req.headers.accept.includes('application/vnd.chibisafe.json')) return next();
+ return res.status(405).json({ message: 'Incorrect `Accept` header provided' });
+ });
+ this.server.use(bodyParser.urlencoded({ extended: true }));
+ this.server.use(bodyParser.json());
+
+ if (process.env.NODE_ENV === 'production') {
+ const accessLogStream = rfs.createStream('access.log', {
+ interval: '1d', // rotate daily
+ path: path.join(__dirname, '../../../logs', 'log')
+ });
+ this.server.use(morgan('combined', { stream: accessLogStream }));
+ }
+
+ // Apply rate limiting to the api only
+ this.server.use('/api/', rateLimiter);
+
+ // Serve the uploads
+ this.server.use(express.static(path.join(__dirname, '../../../uploads')));
+ this.routesFolder = path.join(__dirname, '../routes');
+ }
+
+ registerAllTheRoutes() {
+ jetpack.find(this.routesFolder, { matching: '*.js' }).forEach(routeFile => {
+ const RouteClass = require(path.join('../../../', routeFile));
+ let routes = [RouteClass];
+ if (Array.isArray(RouteClass)) routes = RouteClass;
+ for (const File of routes) {
+ try {
+ const route = new File();
+ this.server[route.method](process.env.ROUTE_PREFIX + route.path, route.authorize.bind(route));
+ log.info(`Found route ${route.method.toUpperCase()} ${process.env.ROUTE_PREFIX}${route.path}`);
+ } catch (e) {
+ log.error(`Failed loading route from file ${routeFile} with error: ${e.message}`);
+ }
+ }
+ });
+ }
+
+ serveNuxt() {
+ // Serve the frontend if we are in production mode
+ if (process.env.NODE_ENV === 'production') {
+ this.server.use(express.static(path.join(__dirname, '../../../dist')));
+ }
+
+ /*
+ For vue router to work with express we need this fallback.
+ After all the routes are loaded and the static files handled and if the
+ user is trying to access a non-mapped route we serve the website instead
+ since it has routes of it's own that don't work if accessed directly
+ */
+ this.server.all('*', (_req, res) => {
+ try {
+ res.sendFile(path.join(__dirname, '../../../dist/index.html'));
+ } catch (error) {
+ res.json({ success: false, message: 'Something went wrong' });
+ }
+ });
+ }
+
+ start() {
+ jetpack.dir('uploads/chunks');
+ jetpack.dir('uploads/thumbs/square');
+ jetpack.dir('uploads/thumbs/preview');
+ this.registerAllTheRoutes();
+ this.serveNuxt();
+ const server = this.server.listen(this.port, () => {
+ log.success(`Backend ready and listening on port ${this.port}`);
+ });
+ server.setTimeout(600000);
+ }
+}
+
+new Server().start();
diff --git a/src/api/utils/Log.js b/src/api/utils/Log.js
new file mode 100644
index 0000000..9a5efc9
--- /dev/null
+++ b/src/api/utils/Log.js
@@ -0,0 +1,36 @@
+const chalk = require('chalk');
+const { dump } = require('dumper.js');
+
+class Log {
+ static info(args) {
+ if (Log.checkIfArrayOrObject(args)) dump(args);
+ else console.log(args); // eslint-disable-line no-console
+ }
+
+ static success(args) {
+ if (Log.checkIfArrayOrObject(args)) dump(args);
+ else console.log(chalk.green(args)); // eslint-disable-line no-console
+ }
+
+ static warn(args) {
+ if (Log.checkIfArrayOrObject(args)) dump(args);
+ else console.log(chalk.yellow(args)); // eslint-disable-line no-console
+ }
+
+ static error(args) {
+ if (Log.checkIfArrayOrObject(args)) dump(args);
+ else console.log(chalk.red(args)); // eslint-disable-line no-console
+ }
+
+ static debug(args) {
+ if (Log.checkIfArrayOrObject(args)) dump(args);
+ else console.log(chalk.gray(args)); // eslint-disable-line no-console
+ }
+
+ static checkIfArrayOrObject(thing) {
+ if (typeof thing === typeof [] || typeof thing === typeof {}) return true;
+ return false;
+ }
+}
+
+module.exports = Log;
diff --git a/src/api/utils/QueryHelper.js b/src/api/utils/QueryHelper.js
new file mode 100644
index 0000000..c26c8eb
--- /dev/null
+++ b/src/api/utils/QueryHelper.js
@@ -0,0 +1,200 @@
+const chrono = require('chrono-node');
+
+class QueryHelper {
+ static parsers = {
+ before: val => QueryHelper.parseChronoList(val),
+ after: val => QueryHelper.parseChronoList(val),
+ tag: val => QueryHelper.sanitizeTags(val)
+ };
+
+ static requirementHandlers = {
+ album: knex => knex
+ .join('albumsFiles', 'files.id', '=', 'albumsFiles.fileId')
+ .join('albums', 'albumsFiles.albumId', '=', 'album.id'),
+ tag: knex => knex
+ .join('fileTags', 'files.id', '=', 'fileTags.fileId')
+ .join('tags', 'fileTags.tagId', '=', 'tags.id')
+ }
+
+ static fieldToSQLMapping = {
+ album: 'albums.name',
+ tag: 'tags.name',
+ before: 'files.createdAt',
+ after: 'files.createdAt'
+ }
+
+ static handlers = {
+ album({ db, knex }, list) {
+ return QueryHelper.generateInclusionForAlbums(db, knex, list);
+ },
+ tag({ db, knex }, list) {
+ list = QueryHelper.parsers.tag(list);
+ return QueryHelper.generateInclusionForTags(db, knex, list);
+ },
+ before({ knex }, list) {
+ list = QueryHelper.parsers.before(list);
+ return QueryHelper.generateBefore(knex, 'before', list);
+ },
+ after({ knex }, list) {
+ list = QueryHelper.parsers.after(list);
+ return QueryHelper.generateAfter(knex, 'after', list);
+ },
+ file({ knex }, list) {
+ return QueryHelper.generateLike(knex, 'name', list);
+ },
+ exclude({ db, knex }, dict) {
+ for (const [key, value] of Object.entries(dict)) {
+ if (key === 'album') {
+ knex = QueryHelper.generateExclusionForAlbums(db, knex, value);
+ }
+ if (key === 'tag') {
+ const parsed = QueryHelper.parsers.tag(value);
+ knex = QueryHelper.generateExclusionForTags(db, knex, parsed);
+ }
+ }
+ return knex;
+ }
+ }
+
+ static verify(field, list) {
+ if (!Array.isArray(list)) {
+ throw new Error(`Expected Array got ${typeof list}`);
+ }
+ if (typeof field !== 'string') {
+ throw new Error(`Expected string got ${typeof field}`);
+ }
+ return true;
+ }
+
+ static getMapping(field) {
+ if (!QueryHelper.fieldToSQLMapping[field]) {
+ throw new Error(`No SQL mapping for ${field} field found`);
+ }
+
+ return QueryHelper.fieldToSQLMapping[field];
+ }
+
+ static generateIn(knex, field, list) {
+ QueryHelper.verify(field, list);
+ return knex.whereIn(QueryHelper.getMapping(field), list);
+ }
+
+ static generateNotIn(knex, field, list) {
+ QueryHelper.verify(field, list);
+ return knex.whereNotExists(QueryHelper.getMapping(field), list);
+ }
+
+ static generateBefore(knex, field, list) {
+ QueryHelper.verify(field, list);
+ }
+
+ static generateAfter(knex, field, list) {
+ QueryHelper.verify(field, list);
+ }
+
+ static parseChronoList(list) {
+ return list.map(e => chrono.parse(e));
+ }
+
+ static sanitizeTags(list) {
+ return list.map(e => e.replace(/\s/g, '_'));
+ }
+
+ static generateInclusionForTags(db, knex, list) {
+ const subQ = db.table('fileTags')
+ .select('fileTags.fileId')
+ .join('tags', 'fileTags.tagId', '=', 'tags.id')
+ .where('fileTags.fileId', db.ref('files.id'))
+ .whereIn('tags.name', list)
+ .groupBy('fileTags.fileId')
+ .havingRaw('count(distinct tags.name) = ?', [list.length]);
+
+ return knex.whereIn('files.id', subQ);
+ }
+
+ static generateInclusionForAlbums(db, knex, list) {
+ const subQ = db.table('albumsFiles')
+ .select('albumsFiles.fileId')
+ .join('albums', 'albumsFiles.albumId', '=', 'albums.id')
+ .where('albumsFiles.fileId', db.ref('files.id'))
+ .whereIn('albums.name', list)
+ .groupBy('albumsFiles.fileId')
+ .havingRaw('count(distinct albums.name) = ?', [list.length]);
+
+ return knex.whereIn('files.id', subQ);
+ }
+
+ static generateExclusionForTags(db, knex, list) {
+ const subQ = db.table('fileTags')
+ .select('fileTags.fileId')
+ .join('tags', 'fileTags.tagId', '=', 'tags.id')
+ .where('fileTags.fileId', db.ref('files.id'))
+ .whereIn('tags.name', list);
+
+ return knex.whereNotIn('files.id', subQ);
+ }
+
+ static generateExclusionForAlbums(db, knex, list) {
+ const subQ = db.table('albumsFiles')
+ .select('albumsFiles.fileId')
+ .join('albums', 'albumsFiles.albumId', '=', 'albums.id')
+ .where('albumsFiles.fileId', db.ref('files.id'))
+ .whereIn('albums.name', list);
+
+ return knex.whereNotIn('files.id', subQ);
+ }
+
+ static generateLike(knex, field, list) {
+ for (const str of list) {
+ knex = knex.where(field, 'like', `${str}%`);
+ }
+
+ return knex;
+ }
+
+ static loadRequirements(knex, queryObject) {
+ // sanity check so we don't accidentally require the same thing twice
+ const loadedRequirements = [];
+
+ for (const key of Object.keys(queryObject)) {
+ if (QueryHelper.requirementHandlers[key] && loadedRequirements.indexOf(key) === -1) {
+ knex = QueryHelper.requirementHandlers[key](knex);
+ loadedRequirements.push(key);
+ }
+ }
+
+ return knex;
+ }
+
+ static mergeTextWithTags(queryObject) {
+ if (queryObject.text) {
+ let { text } = queryObject;
+ if (!Array.isArray(text)) { text = [text]; }
+
+ queryObject.tag = [...(queryObject.tag || []), ...text];
+ }
+
+ if (queryObject.exclude && queryObject.exclude.text) {
+ let { text } = queryObject.exclude;
+ if (!Array.isArray(text)) { text = [text]; }
+
+ queryObject.exclude.tag = [...(queryObject.exclude.tag || []), ...text];
+ }
+
+ return queryObject;
+ }
+
+ static processQuery(db, knex, queryObject) {
+ queryObject = QueryHelper.mergeTextWithTags(queryObject);
+ // knex = QueryHelper.loadRequirements(knex, queryObject);
+ for (const [key, value] of Object.entries(queryObject)) {
+ if (QueryHelper.handlers[key]) {
+ knex = QueryHelper.handlers[key]({ db, knex }, value);
+ }
+ }
+
+ return knex;
+ }
+}
+
+module.exports = QueryHelper;
diff --git a/src/api/utils/ThumbUtil.js b/src/api/utils/ThumbUtil.js
new file mode 100644
index 0000000..d08ecab
--- /dev/null
+++ b/src/api/utils/ThumbUtil.js
@@ -0,0 +1,104 @@
+const jetpack = require('fs-jetpack');
+const path = require('path');
+const sharp = require('sharp');
+const ffmpeg = require('fluent-ffmpeg');
+const previewUtil = require('./videoPreview/FragmentPreview');
+
+const log = require('./Log');
+
+class ThumbUtil {
+ static imageExtensions = ['.jpg', '.jpeg', '.gif', '.png', '.webp'];
+ static videoExtensions = ['.webm', '.mp4', '.wmv', '.avi', '.mov'];
+
+ static thumbPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, 'thumbs');
+ static squareThumbPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, 'thumbs', 'square');
+ static videoPreviewPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, 'thumbs', 'preview');
+
+ static generateThumbnails(filename) {
+ const ext = path.extname(filename).toLowerCase();
+ const output = `${filename.slice(0, -ext.length)}.webp`;
+ const previewOutput = `${filename.slice(0, -ext.length)}.webm`;
+
+ // eslint-disable-next-line max-len
+ if (ThumbUtil.imageExtensions.includes(ext)) return ThumbUtil.generateThumbnailForImage(filename, output);
+ // eslint-disable-next-line max-len
+ if (ThumbUtil.videoExtensions.includes(ext)) return ThumbUtil.generateThumbnailForVideo(filename, previewOutput);
+ return null;
+ }
+
+ static async generateThumbnailForImage(filename, output) {
+ const filePath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, filename);
+
+ const file = await jetpack.readAsync(filePath, 'buffer');
+ await sharp(file)
+ .resize(64, 64)
+ .toFormat('webp')
+ .toFile(path.join(ThumbUtil.squareThumbPath, output));
+ await sharp(file)
+ .resize(225, null)
+ .toFormat('webp')
+ .toFile(path.join(ThumbUtil.thumbPath, output));
+ }
+
+ static async generateThumbnailForVideo(filename, output) {
+ const filePath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, filename);
+
+ ffmpeg(filePath)
+ .thumbnail({
+ timestamps: [0],
+ filename: '%b.webp',
+ folder: ThumbUtil.squareThumbPath,
+ size: '64x64'
+ })
+ .on('error', error => log.error(error.message));
+
+ ffmpeg(filePath)
+ .thumbnail({
+ timestamps: [0],
+ filename: '%b.webp',
+ folder: ThumbUtil.thumbPath,
+ size: '150x?'
+ })
+ .on('error', error => log.error(error.message));
+
+ try {
+ await previewUtil({
+ input: filePath,
+ width: 150,
+ output: path.join(ThumbUtil.videoPreviewPath, output)
+ });
+ } catch (e) {
+ log.error(e);
+ }
+ }
+
+ static getFileThumbnail(filename) {
+ if (!filename) return null;
+ const ext = path.extname(filename).toLowerCase();
+
+ const isImage = ThumbUtil.imageExtensions.includes(ext);
+ const isVideo = ThumbUtil.videoExtensions.includes(ext);
+
+ if (isImage) return { thumb: `${filename.slice(0, -ext.length)}.webp` };
+ if (isVideo) {
+ return {
+ thumb: `${filename.slice(0, -ext.length)}.webp`,
+ preview: `${filename.slice(0, -ext.length)}.webm`
+ };
+ }
+
+ return null;
+ }
+
+ static async removeThumbs({ thumb, preview }) {
+ if (thumb) {
+ await jetpack.removeAsync(path.join(ThumbUtil.thumbPath, thumb));
+ await jetpack.removeAsync(path.join(ThumbUtil.squareThumbPath, thumb));
+ }
+ if (preview) {
+ await jetpack.removeAsync(path.join(ThumbUtil.videoPreviewPath, preview));
+ }
+ }
+}
+
+module.exports = ThumbUtil;
diff --git a/src/api/utils/Util.js b/src/api/utils/Util.js
new file mode 100644
index 0000000..e52fac2
--- /dev/null
+++ b/src/api/utils/Util.js
@@ -0,0 +1,296 @@
+/* eslint-disable no-await-in-loop */
+const jetpack = require('fs-jetpack');
+const randomstring = require('randomstring');
+const path = require('path');
+const JWT = require('jsonwebtoken');
+const db = require('knex')({
+ client: process.env.DB_CLIENT,
+ connection: {
+ host: process.env.DB_HOST,
+ user: process.env.DB_USER,
+ password: process.env.DB_PASSWORD,
+ database: process.env.DB_DATABASE,
+ filename: path.join(__dirname, '../../../database/database.sqlite')
+ },
+ useNullAsDefault: process.env.DB_CLIENT === 'sqlite'
+});
+const moment = require('moment');
+const crypto = require('crypto');
+const Zip = require('adm-zip');
+const uuidv4 = require('uuid/v4');
+
+const log = require('./Log');
+const ThumbUtil = require('./ThumbUtil');
+
+const blockedExtensions = process.env.BLOCKED_EXTENSIONS.split(',');
+
+class Util {
+ static uploadPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER);
+
+ static uuid() {
+ return uuidv4();
+ }
+
+ static isExtensionBlocked(extension) {
+ return blockedExtensions.includes(extension);
+ }
+
+ static constructFilePublicLink(file) {
+ /*
+ TODO: This wont work without a reverse proxy serving both
+ the site and the API under the same domain. Pls fix.
+ */
+ file.url = `${process.env.DOMAIN}/${file.name}`;
+ const { thumb, preview } = ThumbUtil.getFileThumbnail(file.name) || {};
+ if (thumb) {
+ file.thumb = `${process.env.DOMAIN}/thumbs/${thumb}`;
+ file.thumbSquare = `${process.env.DOMAIN}/thumbs/square/${thumb}`;
+ file.preview = preview && `${process.env.DOMAIN}/thumbs/preview/${preview}`;
+ }
+ return file;
+ }
+
+ static getUniqueFilename(name) {
+ const retry = (i = 0) => {
+ const filename = randomstring.generate({
+ length: parseInt(process.env.GENERATED_FILENAME_LENGTH, 10),
+ capitalization: 'lowercase'
+ }) + path.extname(name).toLowerCase();
+
+ // TODO: Change this to look for the file in the db instead of in the filesystem
+ const exists = jetpack.exists(path.join(Util.uploadPath, filename));
+ if (!exists) return filename;
+ if (i < 5) return retry(i + 1);
+ log.error('Couldnt allocate identifier for file');
+ return null;
+ };
+ return retry();
+ }
+
+ static getUniqueAlbumIdentifier() {
+ const retry = async (i = 0) => {
+ const identifier = randomstring.generate({
+ length: parseInt(process.env.GENERATED_ALBUM_LENGTH, 10),
+ capitalization: 'lowercase'
+ });
+ const exists = await db
+ .table('links')
+ .where({ identifier })
+ .first();
+ if (!exists) return identifier;
+ /*
+ It's funny but if you do i++ the asignment never gets done resulting in an infinite loop
+ */
+ if (i < 5) return retry(i + 1);
+ log.error('Couldnt allocate identifier for album');
+ return null;
+ };
+ return retry();
+ }
+
+ static async getFileHash(filename) {
+ const file = await jetpack.readAsync(path.join(Util.uploadPath, filename), 'buffer');
+ if (!file) {
+ log.error(`There was an error reading the file < ${filename} > for hashing`);
+ return null;
+ }
+
+ const hash = crypto.createHash('md5');
+ hash.update(file, 'utf8');
+ return hash.digest('hex');
+ }
+
+ static generateFileHash(data) {
+ const hash = crypto
+ .createHash('md5')
+ .update(data)
+ .digest('hex');
+ return hash;
+ }
+
+ static async checkIfFileExists(db, user, hash) {
+ const exists = await db.table('files')
+ .where(function() { // eslint-disable-line func-names
+ if (user) this.where('userId', user.id);
+ else this.whereNull('userId');
+ })
+ .where({ hash })
+ .first();
+ return exists;
+ }
+
+ static getFilenameFromPath(fullPath) {
+ return fullPath.replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape
+ }
+
+ static async deleteFile(filename, deleteFromDB = false) {
+ const thumbName = ThumbUtil.getFileThumbnail(filename);
+ try {
+ await jetpack.removeAsync(path.join(Util.uploadPath, filename));
+ await ThumbUtil.removeThumbs(thumbName);
+
+ if (deleteFromDB) {
+ await db
+ .table('files')
+ .where('name', filename)
+ .delete();
+ }
+ } catch (error) {
+ log.error(`There was an error removing the file < ${filename} >`);
+ log.error(error);
+ }
+ }
+
+ static async deleteAllFilesFromAlbum(id) {
+ try {
+ const fileAlbums = await db.table('albumsFiles').where({ albumId: id });
+ for (const fileAlbum of fileAlbums) {
+ const file = await db
+ .table('files')
+ .where({ id: fileAlbum.fileId })
+ .first();
+
+ if (!file) continue;
+
+ await this.deleteFile(file.name, true);
+ }
+ } catch (error) {
+ log.error(error);
+ }
+ }
+
+ static async deleteAllFilesFromUser(id) {
+ try {
+ const files = await db.table('files').where({ userId: id });
+ for (const file of files) {
+ await this.deleteFile(file.name, true);
+ }
+ } catch (error) {
+ log.error(error);
+ }
+ }
+
+ static async deleteAllFilesFromTag(id) {
+ try {
+ const fileTags = await db.table('fileTags').where({ tagId: id });
+ for (const fileTag of fileTags) {
+ const file = await db
+ .table('files')
+ .where({ id: fileTag.fileId })
+ .first();
+ if (!file) continue;
+ await this.deleteFile(file.name, true);
+ }
+ } catch (error) {
+ log.error(error);
+ }
+ }
+
+ static async isAuthorized(req) {
+ if (req.headers.token) {
+ const user = await db.table('users').where({ apiKey: req.headers.token }).first();
+ if (!user || !user.enabled) return false;
+ return user;
+ }
+
+ if (!req.headers.authorization) return false;
+ const token = req.headers.authorization.split(' ')[1];
+ if (!token) return false;
+
+ return JWT.verify(token, process.env.SECRET, async (error, decoded) => {
+ if (error) {
+ log.error(error);
+ return false;
+ }
+ const id = decoded ? decoded.sub : '';
+ const iat = decoded ? decoded.iat : '';
+
+ const user = await db
+ .table('users')
+ .where({ id })
+ .first();
+ if (!user || !user.enabled) return false;
+ if (iat && iat < moment(user.passwordEditedAt).format('x')) return false;
+
+ return user;
+ });
+ }
+
+ static createZip(files, album) {
+ try {
+ const zip = new Zip();
+ for (const file of files) {
+ zip.addLocalFile(path.join(Util.uploadPath, file));
+ }
+ zip.writeZip(
+ path.join(
+ __dirname,
+ '../../../',
+ process.env.UPLOAD_FOLDER,
+ 'zips',
+ `${album.userId}-${album.id}.zip`
+ )
+ );
+ } catch (error) {
+ log.error(error);
+ }
+ }
+
+ static generateThumbnails = ThumbUtil.generateThumbnails;
+ static async saveFileToDatabase(req, res, user, db, file, originalFile) {
+ /*
+ Save the upload information to the database
+ */
+ const now = moment.utc().toDate();
+ let insertedId = null;
+ try {
+ /*
+ This is so fucking dumb
+ */
+ if (process.env.DB_CLIENT === 'sqlite3') {
+ insertedId = await db.table('files').insert({
+ userId: user ? user.id : null,
+ name: file.name,
+ original: originalFile.originalname,
+ type: originalFile.mimetype || '',
+ size: file.size,
+ hash: file.hash,
+ ip: req.ip,
+ createdAt: now,
+ editedAt: now
+ });
+ } else {
+ insertedId = await db.table('files').insert({
+ userId: user ? user.id : null,
+ name: file.name,
+ original: originalFile.originalname,
+ type: originalFile.mimetype || '',
+ size: file.size,
+ hash: file.hash,
+ ip: req.ip,
+ createdAt: now,
+ editedAt: now
+ }, 'id');
+ }
+ return insertedId;
+ } catch (error) {
+ console.error('There was an error saving the file to the database');
+ console.error(error);
+ return null;
+ }
+ }
+
+ static async saveFileToAlbum(db, albumId, insertedId) {
+ if (!albumId) return;
+
+ const now = moment.utc().toDate();
+ try {
+ await db.table('albumsFiles').insert({ albumId, fileId: insertedId[0] });
+ await db.table('albums').where('id', albumId).update('editedAt', now);
+ } catch (error) {
+ console.error(error);
+ }
+ }
+}
+
+module.exports = Util;
diff --git a/src/api/utils/generateThumbs.js b/src/api/utils/generateThumbs.js
new file mode 100644
index 0000000..d2cd91b
--- /dev/null
+++ b/src/api/utils/generateThumbs.js
@@ -0,0 +1,17 @@
+require('dotenv').config();
+
+const fs = require('fs');
+const path = require('path');
+
+const ThumbUtil = require('./ThumbUtil');
+
+const start = async () => {
+ const files = fs.readdirSync(path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER));
+ for (const fileName of files) {
+ console.log(`Generating thumb for '${fileName}`);
+ // eslint-disable-next-line no-await-in-loop
+ await ThumbUtil.generateThumbnails(fileName);
+ }
+};
+
+start();
diff --git a/src/api/utils/videoPreview/FragmentPreview.js b/src/api/utils/videoPreview/FragmentPreview.js
new file mode 100644
index 0000000..1d1ee02
--- /dev/null
+++ b/src/api/utils/videoPreview/FragmentPreview.js
@@ -0,0 +1,88 @@
+/* eslint-disable no-bitwise */
+const ffmpeg = require('fluent-ffmpeg');
+const probe = require('ffmpeg-probe');
+
+const noop = () => {};
+
+const getRandomInt = (min, max) => {
+ const minInt = Math.ceil(min);
+ const maxInt = Math.floor(max);
+
+ // eslint-disable-next-line no-mixed-operators
+ return Math.floor(Math.random() * (maxInt - minInt + 1) + minInt);
+};
+
+const getStartTime = (vDuration, fDuration, ignoreBeforePercent, ignoreAfterPercent) => {
+ // by subtracting the fragment duration we can be sure that the resulting
+ // start time + fragment duration will be less than the video duration
+ const safeVDuration = vDuration - fDuration;
+
+ // if the fragment duration is longer than the video duration
+ if (safeVDuration <= 0) {
+ return 0;
+ }
+
+ return getRandomInt(ignoreBeforePercent * safeVDuration, ignoreAfterPercent * safeVDuration);
+};
+
+module.exports = async opts => {
+ const {
+ log = noop,
+
+ // general output options
+ quality = 2,
+ width,
+ height,
+ input,
+ output,
+
+ fragmentDurationSecond = 3,
+ ignoreBeforePercent = 0.25,
+ ignoreAfterPercent = 0.75
+ } = opts;
+
+ const info = await probe(input);
+
+ let { duration } = info.format;
+ duration = parseInt(duration, 10);
+
+ const startTime = getStartTime(duration, fragmentDurationSecond, ignoreBeforePercent, ignoreAfterPercent);
+
+ const result = { startTime, duration };
+
+ await new Promise((resolve, reject) => {
+ let scale = null;
+
+ if (width && height) {
+ result.width = width | 0;
+ result.height = height | 0;
+ scale = `scale=${width}:${height}`;
+ } else if (width) {
+ result.width = width | 0;
+ result.height = ((info.height * width) / info.width) | 0;
+ scale = `scale=${width}:-1`;
+ } else if (height) {
+ result.height = height | 0;
+ result.width = ((info.width * height) / info.height) | 0;
+ scale = `scale=-1:${height}`;
+ } else {
+ result.width = info.width;
+ result.height = info.height;
+ }
+
+ return ffmpeg()
+ .input(input)
+ .inputOptions([`-ss ${startTime}`])
+ .outputOptions(['-vsync', 'vfr'])
+ .outputOptions(['-q:v', quality, '-vf', scale])
+ .outputOptions([`-t ${fragmentDurationSecond}`])
+ .noAudio()
+ .output(output)
+ .on('start', cmd => log && log({ cmd }))
+ .on('end', resolve)
+ .on('error', reject)
+ .run();
+ });
+
+ return result;
+};
diff --git a/src/api/utils/videoPreview/FrameIntervalPreview.js b/src/api/utils/videoPreview/FrameIntervalPreview.js
new file mode 100644
index 0000000..96c6e3a
--- /dev/null
+++ b/src/api/utils/videoPreview/FrameIntervalPreview.js
@@ -0,0 +1,73 @@
+/* eslint-disable no-bitwise */
+const ffmpeg = require('fluent-ffmpeg');
+const probe = require('ffmpeg-probe');
+
+const noop = () => {};
+
+module.exports = async opts => {
+ const {
+ log = noop,
+
+ // general output options
+ quality = 2,
+ width,
+ height,
+ input,
+ output,
+
+ numFrames,
+ numFramesPercent = 0.05
+ } = opts;
+
+ const info = await probe(input);
+ // const numFramesTotal = parseInt(info.streams[0].nb_frames, 10);
+ const { avg_frame_rate: avgFrameRate, duration } = info.streams[0];
+ const [frames, time] = avgFrameRate.split('/').map(e => parseInt(e, 10));
+
+ const numFramesTotal = (frames / time) * duration;
+
+ let numFramesToCapture = numFrames || numFramesPercent * numFramesTotal;
+ numFramesToCapture = Math.max(1, Math.min(numFramesTotal, numFramesToCapture)) | 0;
+ const nthFrame = (numFramesTotal / numFramesToCapture) | 0;
+
+ const result = {
+ output,
+ numFrames: numFramesToCapture
+ };
+
+ await new Promise((resolve, reject) => {
+ let scale = null;
+
+ if (width && height) {
+ result.width = width | 0;
+ result.height = height | 0;
+ scale = `scale=${width}:${height}`;
+ } else if (width) {
+ result.width = width | 0;
+ result.height = ((info.height * width) / info.width) | 0;
+ scale = `scale=${width}:-1`;
+ } else if (height) {
+ result.height = height | 0;
+ result.width = ((info.width * height) / info.height) | 0;
+ scale = `scale=-1:${height}`;
+ } else {
+ result.width = info.width;
+ result.height = info.height;
+ }
+
+ const filter = [`select=not(mod(n\\,${nthFrame}))`, scale].filter(Boolean).join(',');
+
+ ffmpeg(input)
+ .outputOptions(['-vsync', 'vfr'])
+ .outputOptions(['-q:v', quality, '-vf', filter])
+ .noAudio()
+ .outputFormat('webm')
+ .output(output)
+ .on('start', cmd => log && log({ cmd }))
+ .on('end', () => resolve())
+ .on('error', err => reject(err))
+ .run();
+ });
+
+ return result;
+};
diff --git a/src/setup.js b/src/setup.js
new file mode 100644
index 0000000..914d4e3
--- /dev/null
+++ b/src/setup.js
@@ -0,0 +1,167 @@
+/* eslint-disable no-console */
+const randomstring = require('randomstring');
+const jetpack = require('fs-jetpack');
+const qoa = require('qoa');
+
+qoa.config({
+ prefix: '>',
+ underlineQuery: false
+});
+
+async function start() {
+ console.log();
+ console.log('You can manually edit .env file after the wizard to edit values');
+ console.log();
+
+ const wizard = [
+ {
+ type: 'input',
+ query: 'Port to run chibisafe in: (5000)',
+ handle: 'SERVER_PORT'
+ },
+ {
+ type: 'input',
+ query: 'Full domain this instance is gonna be running on (Ex: https://chibisafe.moe):',
+ handle: 'DOMAIN'
+ },
+ {
+ type: 'input',
+ query: 'Name of the service? (Ex: chibisafe):',
+ handle: 'SERVICE_NAME'
+ },
+ {
+ type: 'input',
+ query: 'Maximum allowed upload file size in MB (Ex: 100):',
+ handle: 'MAX_SIZE'
+ },
+ {
+ type: 'confirm',
+ query: 'Allow users to download entire albums in ZIP format? (true)',
+ handle: 'GENERATE_ZIPS',
+ accept: 'y',
+ deny: 'n'
+ },
+ {
+ type: 'confirm',
+ query: 'Allow people to upload files without an account? (true)',
+ handle: 'PUBLIC_MODE',
+ accept: 'y',
+ deny: 'n'
+ },
+ {
+ type: 'confirm',
+ query: 'Allow people to create new accounts? (true)',
+ handle: 'USER_ACCOUNTS',
+ accept: 'y',
+ deny: 'n'
+ },
+ {
+ type: 'input',
+ query: 'Name of the admin account? (admin)',
+ handle: 'ADMIN_ACCOUNT'
+ },
+ {
+ type: 'interactive',
+ query: 'Which predefined database do you want to use?',
+ handle: 'DB_CLIENT',
+ symbol: '>',
+ menu: [
+ 'sqlite3',
+ 'pg',
+ 'mysql'
+ ]
+ },
+ {
+ type: 'input',
+ query: 'Database host (Ignore if you selected sqlite3):',
+ handle: 'DB_HOST'
+ },
+ {
+ type: 'input',
+ query: 'Database user (Ignore if you selected sqlite3):',
+ handle: 'DB_USER'
+ },
+ {
+ type: 'input',
+ query: 'Database password (Ignore if you selected sqlite3):',
+ handle: 'DB_PASSWORD'
+ },
+ {
+ type: 'input',
+ query: 'Database name (Ignore if you selected sqlite3):',
+ handle: 'DB_DATABASE'
+ }
+ ];
+
+ const response = await qoa.prompt(wizard);
+ let envfile = '';
+
+ const defaultSettings = {
+ _1: '# Server settings',
+ SERVER_PORT: response.SERVER_PORT || 5000,
+ WEBSITE_PORT: 5001,
+ ROUTE_PREFIX: '/api',
+ RATE_LIMIT_WINDOW: 2,
+ RATE_LIMIT_MAX: 5,
+ SECRET: randomstring.generate(64),
+
+ _2: '# Service settings',
+ SERVICE_NAME: response.SERVICE_NAME || 'change-me',
+ DOMAIN: response.DOMAIN || `http://localhost:${response.SERVER_PORT}`,
+
+ _3: '# File related settings',
+ CHUNK_SIZE: 90,
+ MAX_SIZE: response.MAX_SIZE || 5000,
+ GENERATE_ZIPS: response.GENERATE_ZIPS || true,
+ GENERATED_FILENAME_LENGTH: 12,
+ GENERATED_ALBUM_LENGTH: 6,
+ MAX_LINKS_PER_ALBUM: 5,
+ UPLOAD_FOLDER: 'uploads',
+ BLOCKED_EXTENSIONS: ['.jar', '.exe', '.msi', '.com', '.bat', '.cmd', '.scr', '.ps1', '.sh'],
+
+ _4: '# User settings',
+ PUBLIC_MODE: response.PUBLIC_MODE || true,
+ USER_ACCOUNTS: response.USER_ACCOUNTS || true,
+ ADMIN_ACCOUNT: response.ADMIN_ACCOUNT || 'admin',
+ ADMIN_PASSWORD: randomstring.generate(16),
+
+ _5: '# Database connection settings',
+ DB_CLIENT: response.DB_CLIENT,
+ DB_HOST: response.DB_HOST || null,
+ DB_USER: response.DB_USER || null,
+ DB_PASSWORD: response.DB_PASSWORD || null,
+ DB_DATABASE: response.DB_DATABASE || null,
+
+ _6: '# Social and sharing settings',
+ META_THEME_COLOR: '#20222b',
+ META_DESCRIPTION: 'Blazing fast file uploader and bunker written in node! 🚀',
+ META_KEYWORDS: 'chibisafe,lolisafe,upload,uploader,file,vue,images,ssr,file uploader,free',
+ META_TWITTER_HANDLE: '@its_pitu'
+ };
+
+ const keys = Object.keys(defaultSettings);
+
+ for (const item of keys) {
+ let prefix = `${item}=`;
+ if (item.startsWith('_1')) {
+ prefix = '';
+ } else if (item.startsWith('_')) {
+ prefix = '\n';
+ }
+ envfile += `${prefix}${defaultSettings[item]}\n`;
+ }
+ jetpack.write('.env', envfile);
+ jetpack.dir('database');
+
+ console.log();
+ console.log('====================================================');
+ console.log('== .env file generated successfully. ==');
+ console.log('====================================================');
+ console.log(`== Your admin password is: ${defaultSettings.ADMIN_PASSWORD} ==`);
+ console.log('== MAKE SURE TO CHANGE IT AFTER YOUR FIRST LOGIN! ==');
+ console.log('====================================================');
+ console.log();
+ setTimeout(() => {}, 1000);
+}
+
+start();
diff --git a/src/site/assets/images/background.jpg b/src/site/assets/images/background.jpg
new file mode 100644
index 0000000..d3e1862
--- /dev/null
+++ b/src/site/assets/images/background.jpg
Binary files differ
diff --git a/src/site/assets/images/blank.png b/src/site/assets/images/blank.png
new file mode 100644
index 0000000..f1ea2eb
--- /dev/null
+++ b/src/site/assets/images/blank.png
Binary files differ
diff --git a/src/site/assets/images/blank2.jpg b/src/site/assets/images/blank2.jpg
new file mode 100644
index 0000000..dbf57ad
--- /dev/null
+++ b/src/site/assets/images/blank2.jpg
Binary files differ
diff --git a/src/site/assets/images/blank_darker.png b/src/site/assets/images/blank_darker.png
new file mode 100644
index 0000000..61f2944
--- /dev/null
+++ b/src/site/assets/images/blank_darker.png
Binary files differ
diff --git a/src/site/assets/images/logo.png b/src/site/assets/images/logo.png
new file mode 100644
index 0000000..f72de04
--- /dev/null
+++ b/src/site/assets/images/logo.png
Binary files differ
diff --git a/src/site/assets/styles/_bulma_colors_extender.scss b/src/site/assets/styles/_bulma_colors_extender.scss
new file mode 100644
index 0000000..ce406c4
--- /dev/null
+++ b/src/site/assets/styles/_bulma_colors_extender.scss
@@ -0,0 +1,16 @@
+// Import the initial variables
+@import "../../../../node_modules/bulma/sass/utilities/initial-variables";
+@import "../../../../node_modules/bulma/sass/utilities/functions";
+
+// Setup our custom colors
+$chibisafe: #323846;
+$chibisafe-invert: findColorInvert($chibisafe);
+
+// XXX: EXPERIMENTAL, CHECK IF WE NEED ORIGINAL PRIMARY ANYWHERE
+// $primary: $chibisafe;
+// $primary-invert: $chibisafe-invert;
+
+// declare custom colors
+$custom-colors: (
+ "chibisafe":($chibisafe, $chibisafe-invert)
+);
diff --git a/src/site/assets/styles/_colors.scss b/src/site/assets/styles/_colors.scss
new file mode 100644
index 0000000..e8b17c4
--- /dev/null
+++ b/src/site/assets/styles/_colors.scss
@@ -0,0 +1,83 @@
+// $basePink: #EC1A55;
+$base-1: #292e39;
+$base-2: #2e3440;
+$base-3: #3b4252;
+$base-4: #434c5e;
+$base-5: #4c566a;
+
+$background: #1e2430;
+$backgroundAccent: #20222b;
+$backgroundAccentLighter: #53555e;
+$backgroundLight1: #f5f6f8;
+
+$scheme-main: $background;
+$scheme-main-bis: $backgroundAccent;
+$scheme-main-ter: $backgroundAccentLighter;
+
+// customize navbar
+$navbar-background-color: $backgroundAccent;
+$navbar-item-color: #f5f6f8;
+
+// $defaultTextColor: #4a4a4a;
+$defaultTextColor: rgb(236, 239, 244);
+$textColor: #c7ccd8;
+$textColorHighlight: white;
+
+$input-hover-color: $textColor;
+
+$basePink: #ff015b;
+$basePinkHover: rgb(196, 4, 71);
+$baseBlue: #30a9ed;
+$baseBlueHover: rgb(21, 135, 201);
+
+$uploaderDropdownColor: #797979;
+
+$boxShadow: 0 10px 15px rgba(4, 39, 107, 0.2);
+$boxShadowLight: rgba(15, 17, 21, 0.35) 0px 6px 9px 0px;
+
+// pagination
+$pagination-color: $defaultTextColor;
+
+$pagination-focus-color: $textColorHighlight;
+$pagination-focus-border-color: $textColorHighlight;
+
+$pagination-active-color: $textColorHighlight;
+$pagination-active-border-color: $textColorHighlight;
+
+$pagination-hover-color: $textColorHighlight;
+$pagination-hover-border-color: $textColorHighlight;
+
+$pagination-current-background-color: $base-3;
+$pagination-current-border-color: $base-2;
+
+// loading
+$loading-background: rgba(0, 0, 0, 0.8);
+$loading-background: rgba(40, 40, 40, 0.66);
+
+// dialogs
+$modal-card-title-color: $textColor;
+$modal-card-body-background-color: $background;
+$modal-card-head-background-color: $backgroundAccent;
+$modal-card-head-border-bottom: 1px solid rgba(255, 255, 255, 0.1098);
+$modal-card-foot-border-top: 1px solid rgba(255, 255, 255, 0.1098);
+
+// sidebar
+$sidebar-background: $base-1;
+$sidebar-box-shadow: none;
+
+//
+$menu-item-color: $textColor;
+$menu-item-hover-color: $textColorHighlight;
+$menu-item-active-background-color: $backgroundAccent;
+
+// dropdown
+$dropdown-content-background-color: $background;
+$dropdown-item-hover-background-color: $backgroundAccentLighter;
+$dropdown-item-color: $textColor;
+$dropdown-item-hover-color: $textColorHighlight;
+$dropdown-item-active-color: $textColorHighlight;
+$dropdown-item-active-background-color: hsl(171, 100%, 41%); // $primary
+
+// tags
+$tag-background-color: $base-2;
+$tag-color: $textColor;
diff --git a/src/site/assets/styles/bulma-divider.scss b/src/site/assets/styles/bulma-divider.scss
new file mode 100644
index 0000000..713c7e4
--- /dev/null
+++ b/src/site/assets/styles/bulma-divider.scss
@@ -0,0 +1,164 @@
+/*! @creativebulma/bulma-divider v1.1.0 | (c) 2020 Gaetan | MIT License | https://github.com/CreativeBulma/bulma-divider */
+@-webkit-keyframes spinAround {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(359deg);
+ }
+}
+
+@keyframes spinAround {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(359deg);
+ }
+}
+
+/* line 17, src/sass/app.sass */
+.divider {
+ position: relative;
+ display: flex;
+ align-items: center;
+ text-transform: uppercase;
+ color: #7a7a7a;
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: .5px;
+ margin: 25px 0;
+}
+
+/* line 28, src/sass/app.sass */
+.divider::after, .divider::before {
+ content: '';
+ display: block;
+ flex: 1;
+ height: 1px;
+ background-color: #dbdbdb;
+}
+
+/* line 37, src/sass/app.sass */
+.divider:not(.is-right)::after {
+ margin-left: 10px;
+}
+
+/* line 41, src/sass/app.sass */
+.divider:not(.is-left)::before {
+ margin-right: 10px;
+}
+
+/* line 45, src/sass/app.sass */
+.divider.is-left::before {
+ display: none;
+}
+
+/* line 49, src/sass/app.sass */
+.divider.is-right::after {
+ display: none;
+}
+
+/* line 52, src/sass/app.sass */
+.divider.is-vertical {
+ flex-direction: column;
+ margin: 0 25px;
+}
+
+/* line 56, src/sass/app.sass */
+.divider.is-vertical::after, .divider.is-vertical::before {
+ height: auto;
+ width: 1px;
+}
+
+/* line 61, src/sass/app.sass */
+.divider.is-vertical::after {
+ margin-left: 0;
+ margin-top: 10px;
+}
+
+/* line 65, src/sass/app.sass */
+.divider.is-vertical::before {
+ margin-right: 0;
+ margin-bottom: 10px;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-white::after, .divider.is-white::before {
+ background-color: white;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-black::after, .divider.is-black::before {
+ background-color: #0a0a0a;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-light::after, .divider.is-light::before {
+ background-color: whitesmoke;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-dark::after, .divider.is-dark::before {
+ background-color: #363636;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-primary::after, .divider.is-primary::before {
+ background-color: #00d1b2;
+}
+
+/* line 80, src/sass/app.sass */
+.divider.is-primary.is-light::after, .divider.is-primary.is-light::before {
+ background-color: #ebfffc;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-link::after, .divider.is-link::before {
+ background-color: #3273dc;
+}
+
+/* line 80, src/sass/app.sass */
+.divider.is-link.is-light::after, .divider.is-link.is-light::before {
+ background-color: #eef3fc;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-info::after, .divider.is-info::before {
+ background-color: #3298dc;
+}
+
+/* line 80, src/sass/app.sass */
+.divider.is-info.is-light::after, .divider.is-info.is-light::before {
+ background-color: #eef6fc;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-success::after, .divider.is-success::before {
+ background-color: #48c774;
+}
+
+/* line 80, src/sass/app.sass */
+.divider.is-success.is-light::after, .divider.is-success.is-light::before {
+ background-color: #effaf3;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-warning::after, .divider.is-warning::before {
+ background-color: #ffdd57;
+}
+
+/* line 80, src/sass/app.sass */
+.divider.is-warning.is-light::after, .divider.is-warning.is-light::before {
+ background-color: #fffbeb;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-danger::after, .divider.is-danger::before {
+ background-color: #f14668;
+}
+
+/* line 80, src/sass/app.sass */
+.divider.is-danger.is-light::after, .divider.is-danger.is-light::before {
+ background-color: #feecf0;
+}
diff --git a/src/site/assets/styles/dropzone.scss b/src/site/assets/styles/dropzone.scss
new file mode 100644
index 0000000..cff1223
--- /dev/null
+++ b/src/site/assets/styles/dropzone.scss
@@ -0,0 +1,575 @@
+/*
+ * The MIT License
+ * Copyright (c) 2012 Matias Meno <[email protected]>
+ */
+
+ @-webkit-keyframes passing-through {
+ 0% {
+ opacity: 0;
+ transform: translateY(40px);
+ }
+ 30%,
+ 70% {
+ opacity: 1;
+ transform: translateY(0px);
+ }
+ 100% {
+ opacity: 0;
+ transform: translateY(-40px);
+ }
+}
+@-moz-keyframes passing-through {
+ 0% {
+ opacity: 0;
+ transform: translateY(40px);
+ }
+ 30%,
+ 70% {
+ opacity: 1;
+ transform: translateY(0px);
+ }
+ 100% {
+ opacity: 0;
+ transform: translateY(-40px);
+ }
+}
+@keyframes passing-through {
+ 0% {
+ opacity: 0;
+ transform: translateY(40px);
+ }
+ 30%,
+ 70% {
+ opacity: 1;
+ transform: translateY(0px);
+ }
+ 100% {
+ opacity: 0;
+ transform: translateY(-40px);
+ }
+}
+@-webkit-keyframes slide-in {
+ 0% {
+ opacity: 0;
+ transform: translateY(40px);
+ }
+ 30% {
+ opacity: 1;
+ transform: translateY(0px);
+ }
+}
+@-moz-keyframes slide-in {
+ 0% {
+ opacity: 0;
+ transform: translateY(40px);
+ }
+ 30% {
+ opacity: 1;
+ transform: translateY(0px);
+ }
+}
+@keyframes slide-in {
+ 0% {
+ opacity: 0;
+ transform: translateY(40px);
+ }
+ 30% {
+ opacity: 1;
+ transform: translateY(0px);
+ }
+}
+@-webkit-keyframes pulse {
+ 0% {
+ transform: scale(1);
+ }
+ 10% {
+ transform: scale(1.1);
+ }
+ 20% {
+ transform: scale(1);
+ }
+}
+@-moz-keyframes pulse {
+ 0% {
+ transform: scale(1);
+ }
+ 10% {
+ transform: scale(1.1);
+ }
+ 20% {
+ transform: scale(1);
+ }
+}
+@keyframes pulse {
+ 0% {
+ transform: scale(1);
+ }
+ 10% {
+ transform: scale(1.1);
+ }
+ 20% {
+ transform: scale(1);
+ }
+}
+.dropzone,
+.dropzone * {
+ box-sizing: border-box;
+}
+.dropzone {
+ min-height: 75px;
+ border: 1px solid #00000061;
+ background: rgba(0, 0, 0, 0.15);
+ border-radius: .3em;
+}
+.dropzone.dz-clickable {
+ cursor: pointer;
+}
+.dropzone.dz-clickable * {
+ cursor: default;
+}
+.dropzone.dz-clickable .dz-message,
+.dropzone.dz-clickable .dz-message * {
+ cursor: pointer;
+}
+.dropzone.dz-started .dz-message {
+ display: none;
+}
+.dropzone.dz-drag-hover {
+ border-style: solid;
+}
+.dropzone.dz-drag-hover .dz-message {
+ opacity: 0.5;
+}
+.dropzone .dz-message {
+ text-align: center;
+ margin: 2em 0;
+ font-size: .875em;
+ font-weight: 400;
+ line-height: 1.5;
+}
+.dropzone .dz-preview {
+ position: relative;
+ display: inline-block;
+ vertical-align: top;
+ min-height: 40px;
+ margin: 1em;
+ margin-bottom: 0.5em;
+ width: calc(100% - 2em);
+ background: #808080;
+ border-radius: .3em;
+}
+
+.dropzone .dz-preview:not(:nth-child(2)) {
+ margin-top: 0px;
+}
+
+.dropzone .dz-preview:last-child {
+ margin-bottom: 1em;
+}
+.dropzone .dz-preview:hover {
+ z-index: 1000;
+}
+.dropzone .dz-preview:hover .dz-details {
+ opacity: 1;
+}
+.dropzone .dz-preview.dz-file-preview .dz-image {
+ border-radius: 20px;
+ background: #999;
+ background: linear-gradient(to bottom, #eee, #ddd);
+}
+.dropzone .dz-preview.dz-file-preview .dz-details {
+ opacity: 1;
+}
+.dropzone .dz-preview.dz-image-preview {
+ background: white;
+}
+.dropzone .dz-preview.dz-image-preview .dz-details {
+ transition: opacity 0.2s linear;
+}
+.dropzone .dz-preview .dz-remove {
+ font-size: 14px;
+ text-align: center;
+ display: block;
+ cursor: pointer;
+ border: none;
+}
+.dropzone .dz-preview .dz-remove:hover {
+ text-decoration: underline;
+}
+.dropzone .dz-preview:hover .dz-details {
+ opacity: 1;
+}
+.dropzone .dz-preview .dz-details {
+ z-index: 20;
+ position: absolute;
+ top: 0;
+ left: 0;
+ opacity: 0;
+ font-size: 13px;
+ min-width: 100%;
+ max-width: 100%;
+ // padding: 2em 1em;
+ text-align: center;
+ color: rgba(0, 0, 0, 0.9);
+ line-height: 150%;
+}
+.dropzone .dz-preview .dz-details .dz-size {
+ // margin-bottom: 1em;
+ font-size: 12px !important;
+ transform: translateY(-5px);
+}
+.dropzone .dz-preview .dz-details .dz-filename {
+ white-space: nowrap;
+ font-size: .75em;
+ line-height: 1.5;
+ width: 225px;
+ margin-top: 3px;
+}
+.dropzone .dz-preview .dz-details .dz-filename:hover span {
+ border: 1px solid rgba(200, 200, 200, 0.8);
+ background-color: rgba(255, 255, 255, 0.8);
+}
+.dropzone .dz-preview .dz-details .dz-filename:not(:hover) {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.dropzone .dz-preview .dz-details .dz-filename:not(:hover) span {
+ border: 1px solid transparent;
+}
+.dropzone .dz-preview .dz-details .dz-filename span,
+.dropzone .dz-preview .dz-details .dz-size span {
+ /*
+ background-color: rgba(255, 255, 255, 0.4);
+ padding: 0 0.4em;
+ border-radius: 3px;
+ */
+}
+.dropzone .dz-preview .result a {
+ cursor: pointer;
+ color: white;
+ text-transform: uppercase;
+ line-height: 30px;
+ &:hover {
+ color: white;
+ text-decoration: underline;;
+ }
+}
+.dropzone .dz-preview:hover .dz-image img {
+ transform: scale(1.05, 1.05);
+ filter: blur(8px);
+}
+.dropzone .dz-preview .dz-image {
+ border-radius: 20px;
+ overflow: hidden;
+ width: 120px;
+ height: 120px;
+ position: relative;
+ display: block;
+ z-index: 10;
+}
+.dropzone .dz-preview .dz-image img {
+ display: block;
+}
+.dropzone .dz-preview.dz-success .dz-success-mark {
+ animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1);
+}
+.dropzone .dz-preview.dz-error .dz-error-mark {
+ opacity: 1;
+ animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1);
+}
+.dropzone .dz-preview .dz-success-mark,
+.dropzone .dz-preview .dz-error-mark {
+ pointer-events: none;
+ opacity: 0;
+ z-index: 500;
+ position: absolute;
+ display: block;
+ top: 50%;
+ left: 50%;
+ margin-left: -27px;
+ margin-top: -27px;
+}
+.dropzone .dz-preview .dz-success-mark svg,
+.dropzone .dz-preview .dz-error-mark svg {
+ display: block;
+ width: 54px;
+ height: 54px;
+}
+.dropzone .dz-preview.dz-processing .dz-progress {
+ opacity: 1;
+ transition: all 0.2s linear;
+}
+.dropzone .dz-preview.dz-complete .dz-progress {
+ opacity: 0;
+ transition: opacity 0.4s ease-in;
+}
+.dropzone .dz-preview:not(.dz-processing) .dz-progress {
+ animation: pulse 6s ease infinite;
+}
+.dropzone .dz-preview .dz-progress {
+ opacity: 1;
+ z-index: 1000;
+ pointer-events: none;
+ position: absolute;
+ margin-top: -8px;
+ width: 80px;
+ margin-left: -40px;
+ background: rgba(255, 255, 255, 0.9);
+ transform: scale(1);
+ border-radius: 8px;
+ overflow: hidden;
+ right: 10px;
+ left: initial;
+ margin: 0px;
+ height: 5px;
+ top: 17px;
+}
+.dropzone .dz-preview .dz-progress .dz-upload {
+ // background: #333;
+ // background: linear-gradient(to bottom, #666, #444);
+ background: #0b6312;
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 0;
+ transition: width 300ms ease-in-out;
+}
+.dropzone .dz-preview.dz-error .dz-error-message {
+ display: block;
+}
+.dropzone .dz-preview.dz-error:hover .dz-error-message {
+ opacity: 1;
+ pointer-events: auto;
+}
+.dropzone .dz-preview .dz-error-message {
+ pointer-events: none;
+ z-index: 1000;
+ position: absolute;
+ display: block;
+ display: none;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ border-radius: 8px;
+ font-size: 13px;
+ top: 130px;
+ left: -10px;
+ width: 140px;
+ background: #be2626;
+ background: linear-gradient(to bottom, #be2626, #a92222);
+ padding: 0.5em 1.2em;
+ color: white;
+}
+.dropzone .dz-preview .dz-error-message:after {
+ content: '';
+ position: absolute;
+ top: -6px;
+ left: 64px;
+ width: 0;
+ height: 0;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-bottom: 6px solid #be2626;
+}
+/*
+.vue-dropzone {
+ border: 2px solid #E5E5E5;
+ font-family: 'Arial', sans-serif;
+ letter-spacing: 0.2px;
+ color: #777;
+ transition: background-color 0.2s linear;
+}
+.vue-dropzone:hover {
+ background-color: #F6F6F6;
+}
+*/
+.vue-dropzone i {
+ color: #CCC;
+}
+.vue-dropzone .dz-preview .dz-image {
+ border-radius: 0;
+ width: 100%;
+ height: 100%;
+}
+.vue-dropzone .dz-preview .dz-image img:not([src]) {
+ width: 200px;
+ height: 200px;
+}
+.vue-dropzone .dz-preview .dz-image:hover img {
+ transform: none;
+ -webkit-filter: none;
+}
+.vue-dropzone .dz-preview .dz-details {
+ bottom: 0;
+ top: 0;
+ color: white;
+ background-color: transparent;
+ // background-color: rgba(33, 150, 243, 0.8);
+ // transition: opacity .2s linear;
+ transition: background-color .2s linear;
+ text-align: left;
+ border-radius: .3em;
+ height: 40px;
+ padding-left: 15px;
+}
+
+.vue-dropzone .dz-preview.dz-success .dz-details {
+ background-color: #22a061;
+}
+
+.vue-dropzone .dz-preview.dz-error .dz-details {
+ background-color: #c44e47;
+}
+
+.vue-dropzone .dz-preview .dz-details .dz-filename {
+ overflow: hidden;
+}
+
+.vue-dropzone .dz-preview .dz-details .dz-filename span {
+ font-size: 1.25em;
+}
+.vue-dropzone .dz-preview .dz-details .dz-filename span,
+.vue-dropzone .dz-preview .dz-details .dz-size span {
+ background-color: transparent;
+ // font-size: .625em;
+ // opacity: .5;
+ opacity: 1;
+ transition: opacity .25s ease-in-out;
+ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
+}
+
+.vue-dropzone .dz-preview .dz-details .dz-size span,
+.vue-dropzone .dz-preview .dz-details .dz-size span strong {
+ opacity: .75;
+ color: white;
+ font-size: .625em;
+}
+.vue-dropzone .dz-preview .dz-details .dz-filename:not(:hover) span {
+ border: none;
+}
+.vue-dropzone .dz-preview .dz-details .dz-filename:hover span {
+ background-color: transparent;
+ border: none;
+}
+.vue-dropzone .dz-preview .dz-progress .dz-upload {
+ // background: #cccccc;
+ background: #8bc34a;
+}
+.vue-dropzone .dz-preview .dz-remove {
+ position: absolute;
+ z-index: 30;
+ color: white;
+ margin-left: 15px;
+ padding: 10px;
+ top: inherit;
+ bottom: 15px;
+ border: 2px white solid;
+ text-decoration: none;
+ text-transform: uppercase;
+ font-size: 0.8rem;
+ font-weight: 800;
+ letter-spacing: 1.1px;
+ opacity: 0;
+}
+.vue-dropzone .dz-preview:hover .dz-remove {
+ opacity: 1;
+}
+.vue-dropzone .dz-preview .dz-success-mark,
+.vue-dropzone .dz-preview .dz-error-mark {
+ margin-left: auto;
+ margin-top: auto;
+ width: 100%;
+ top: 35%;
+ left: 0;
+}
+.vue-dropzone .dz-preview .dz-success-mark svg,
+.vue-dropzone .dz-preview .dz-error-mark svg {
+ margin-left: auto;
+ margin-right: auto;
+}
+.vue-dropzone .dz-preview .dz-error-message {
+ top: calc(15%);
+ margin-left: auto;
+ margin-right: auto;
+ left: 0;
+ width: 100%;
+}
+.vue-dropzone .dz-preview .dz-error-message:after {
+ bottom: -6px;
+ top: initial;
+ border-top: 6px solid #a92222;
+ border-bottom: none;
+}
+
+.dz-size span, .dz-size span strong {
+ font-size: 0.9em !important;
+}
+
+.vue-dropzone .dz-preview .result,
+.vue-dropzone .dz-preview .error {
+ display: none;
+}
+.vue-dropzone .dz-preview.dz-success .result,
+.vue-dropzone .dz-preview.dz-error .error {
+ display: inline-block;
+ position: absolute;
+ z-index: 50;
+ right: 5px;
+ top: 6px;
+ transition: display 0.2s linear;
+ cursor: pointer;
+
+ > div {
+ margin-right: 5px;
+ display: inline-block;
+ cursor: pointer;
+ span {
+ width: 2em;
+ height: 2em;
+ color: #fff;
+ outline: none;
+ border-radius: 50%;
+ background-color: rgba(0,0,0,.5);
+ display: block;
+ cursor: pointer;
+ a { cursor: pointer; }
+ i {
+ color: white;
+ position: absolute;
+ top: 4px;
+ left: 4px;
+ cursor: pointer;
+ }
+ }
+ }
+}
+
+.vue-dropzone .dz-preview.dz-error .error {
+ span.error-message {
+ display: none;
+ width: 250px;
+ background-color: black;
+ color: #fff;
+ text-align: center;
+ padding: 5px 0;
+ border-radius: 6px;
+ top: -8px;
+ height: auto;
+ left: 60px;
+ position: absolute;
+ transition: display 0.2s linear;
+ }
+
+ > div > span {
+ position: relative;
+ &:hover {
+ span.error-message {
+ display: inline-table;
+ }
+ }
+ }
+ i {
+ top: 3px !important;
+ left: 5px !important;
+ }
+}
diff --git a/src/site/assets/styles/style.scss b/src/site/assets/styles/style.scss
new file mode 100644
index 0000000..1152f43
--- /dev/null
+++ b/src/site/assets/styles/style.scss
@@ -0,0 +1,414 @@
+// Let's first take care of having the customized colors ready.
+@import './_bulma_colors_extender.scss';
+@import './_colors.scss';
+
+// Bulma/Buefy customization
+@import '../../../../node_modules/bulma/sass/utilities/_all.sass';
+
+$body-size: 14px !default;
+$family-primary: 'Nunito', BlinkMacSystemFont, -apple-system, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
+ 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
+$size-normal: 1rem;
+
+/* @import '../../../node_modules/bulma/bulma.sass';
+@import '../../../node_modules/buefy/src/scss/buefy.scss'; */
+
+@import "~bulma";
+@import "~buefy/src/scss/buefy";
+
+@import '@mdi/font/css/materialdesignicons.css';
+
+@import './bulma-divider.scss';
+
+html, body, #__nuxt, #__layout, .default-body, #app {
+ // height: 100% !important;
+}
+
+html {
+ // font-size: 100%;
+ font-size: 14px;
+ background-color: $base-1;
+}
+
+a {
+ color: #5e81ac;
+ &:hover {
+ color: #81a1c1;
+ text-decoration: underline;
+ }
+}
+body,
+p,
+h1,
+h1.title,
+h2.subtitle,
+label.label,
+strong {
+ color: $textColor;
+}
+label.label {
+ font-weight: 400;
+}
+
+h4 {
+ font-size: 2em;
+ font-weight: 700;
+ line-height: 1.25em;
+}
+
+@for $i from 1 through 10 {
+ .mt#{$i} {
+ margin-top: $i + em !important;
+ }
+ .mb#{$i} {
+ margin-bottom: $i + em !important;
+ }
+ .ml#{$i} {
+ margin-left: $i + em !important;
+ }
+ .mr#{$i} {
+ margin-right: $i + em !important;
+ }
+}
+
+.text-center {
+ text-align: center;
+}
+
+hr {
+ background-color: #c7c7c757;
+ height: 1px;
+}
+// Bulma color changes.
+.tooltip.is-top.is-primary:before {
+ border-top: 5px solid #20222b;
+}
+.tooltip.is-primary:after {
+ background: #20222b;
+}
+
+div#drag-overlay {
+ position: fixed;
+ top: 0px;
+ left: 0px;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ z-index: 100;
+ div.background {
+ background: rgba(0, 0, 0, 0.9);
+ position: absolute;
+ top: 3%;
+ left: 3%;
+ width: 94%;
+ height: 94%;
+ border: 2px dashed #fff;
+ border-radius: 10px;
+ box-shadow: 0 0 0 4px #ffffff00, 2px 1px 6px 4px rgba(10, 10, 0, 0.5);
+ }
+
+ div.drop {
+ width: 100%;
+ color: white;
+ position: absolute;
+ height: 100%;
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ font-size: 2em;
+ }
+}
+
+section.hero {
+ &.dashboard {
+ // background-color: $backgroundLight1 !important;
+ div.hero-body {
+ align-items: baseline;
+ }
+ }
+}
+
+section input,
+section a.button {
+ font-size: 14px !important;
+}
+section input,
+section p.control a.button {
+ border-left: 0px !important;
+ border-top: 0px !important;
+ border-right: 0px !important;
+ border-radius: 0px !important;
+ box-shadow: 0 0 0 !important;
+}
+
+section p.control a.button {
+ margin-left: 10px !important;
+}
+section p.control button {
+ height: 100%;
+ font-size: 12px;
+}
+
+.switch input[type='checkbox'] + .check:before {
+ background: #fbfbfb;
+}
+
+/*
+ Register and Login forms
+*/
+
+section.hero.is-login,
+section.hero.is-register {
+ a {
+ font-size: 1.25em;
+ line-height: 2.5em;
+
+ &.button {
+ font-size: 14px !important;
+ }
+ }
+ input {
+ background: #323846;
+ border-radius: 0 !important;
+ }
+}
+/*
+section#login,
+section#register { background-color: $backgroundLight1 !important; }
+section#login input,
+section#register input,
+section#login a.button,
+section#register a.button {
+ font-size: 14px !important;
+}
+section#login input,
+section#register input,
+section#login p.control a.button,
+section#register p.control a.button {
+ border-left: 0px !important;
+ border-top: 0px !important;
+ border-right: 0px !important;
+ border-radius: 0px !important;
+ box-shadow: 0 0 0 !important;
+}
+
+section#login p.control a.button,
+section#register p.control a.button { margin-left: 10px !important; }
+section#login p.control a#loginBtn,
+section#register p.control a#loginBtn { border-right: 0px !important; }
+section#login p.control a#registerBtn,
+section#register p.control a#registerBtn { border-left: 0px !important; }
+
+section#login a.is-text,
+section#register a.is-text {
+ font-size: 1.25em;
+ line-height: 2.5em;
+}
+
+.modal-card-head, .modal-card-foot {
+ background: $backgroundLight1;
+}
+*/
+
+.switch {
+ margin-top: 5px;
+}
+
+/* button.button.is-primary {
+ background-color: #323846;
+ border: 2px solid #21252d;
+ color: $textColor;
+ font-size: 1rem;
+ border-top: 0;
+ border-left: 0;
+ border-right: 0;
+ &:hover {
+ background-color: $base-2;
+ }
+ &.big {
+ font-size: 1.25rem;
+ }
+} */
+svg.waves {
+ display: block;
+ bottom: -1px;
+ left: 0px;
+ right: 0px;
+ width: 100%;
+ background-color: transparent;
+ pointer-events: none;
+ user-select: none;
+ overflow: hidden;
+}
+div.field-body > div.field {
+ text-align: left;
+}
+table.table {
+ background: $base-2;
+ color: $textColor;
+ border: 0;
+ thead {
+ th,
+ td {
+ color: $textColor;
+ }
+ }
+ tfoot {
+ background: $base-1;
+ tr.table-footer {
+ .wrapper {
+ display: flex;
+ color: $textColor;
+ justify-content: space-evenly;
+ }
+ }
+ }
+ th,
+ td {
+ border-color: #ffffff1c;
+ }
+}
+
+.chibisafe-input input,
+.chibisafe-select select,
+.chibisafe-textarea textarea {
+ border: 2px solid #21252d;
+ border-radius: 0.3em !important;
+ background: rgba(0, 0, 0, 0.15);
+ padding: 1rem;
+ color: $textColor;
+ height: 3rem;
+ &:focus,
+ &:hover {
+ border: 2px solid #21252d;
+ }
+ &::placeholder {
+ color: $textColor;
+ }
+}
+
+.chibisafe-input .icon {
+ color: #323846 !important;
+}
+
+
+// vue-bar
+.vb > .vb-dragger {
+ z-index: 5;
+ width: 12px;
+ right: 0;
+}
+
+.vb > .vb-dragger > .vb-dragger-styler {
+ backface-visibility: hidden;
+ transform: rotate3d(0, 0, 0, 0);
+ transition: background-color 100ms ease-out, margin 100ms ease-out, height 100ms ease-out;
+ background-color: $backgroundAccent;
+ margin: 5px 5px 5px 0;
+ border-radius: 20px;
+ height: calc(100% - 10px);
+ display: block;
+}
+
+.vb.vb-scrolling-phantom > .vb-dragger > .vb-dragger-styler {
+ background-color: $backgroundAccentLighter;
+}
+
+.vb > .vb-dragger:hover > .vb-dragger-styler {
+ background-color: $backgroundAccentLighter;
+ margin: 0px;
+ height: 100%;
+}
+
+.vb.vb-dragging > .vb-dragger > .vb-dragger-styler {
+ background-color: $backgroundAccentLighter;
+ margin: 0px;
+ height: 100%;
+}
+
+.vb.vb-dragging-phantom > .vb-dragger > .vb-dragger-styler {
+ background-color: $backgroundAccentLighter;
+}
+
+.vb-content {
+ overflow: auto !important;
+}
+
+// helpers
+.has-text-default {
+ color: $textColor;
+}
+
+.has-text-default-highlight {
+ color: $textColorHighlight;
+}
+
+.is-height-max-content {
+ height: max-content;
+}
+
+.pagination a,
+.pagination a:hover {
+ text-decoration: none;
+}
+
+// fix control icons
+.control.has-icons-left .icon, .control.has-icons-right .icon {
+ height: 3rem;
+ padding-right: 1rem;
+}
+
+.is-marginless {
+ margin: 0 !important;
+}
+
+.has-centered-items {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.chibisafe-on-border.field.is-floating-label .label:before {
+ background-color: $chibisafe;
+}
+
+.is-chibisafe.divider::after, .is-chibisafe.divider::before {
+ background-color: #21252d;
+}
+
+.chibisafe.taginput {
+ .taginput-container {
+ background-color: #21252d;
+ border: 2px solid #21252d;
+ border-radius: 0.3em !important;
+
+ input {
+ background-color: #21252d;
+ color: $textColor;
+ &::placeholder {
+ color: $textColor;
+ }
+ }
+
+ .icon {
+ padding-left: 15px;
+ }
+
+ &:focus,
+ &:hover,
+ &:active {
+ border: 2px solid #21252d;
+ }
+ }
+}
+
+.dropdown-content a {
+ text-decoration: none;
+}
+
+.button {
+ color: $textColor;
+ &:hover {
+ color: white;
+ }
+}
diff --git a/src/site/components/album/AlbumDetails.vue b/src/site/components/album/AlbumDetails.vue
new file mode 100644
index 0000000..81819b2
--- /dev/null
+++ b/src/site/components/album/AlbumDetails.vue
@@ -0,0 +1,294 @@
+<template>
+ <div class="details">
+ <h2>Public links for this album:</h2>
+
+ <b-table
+ :data="details.links || []"
+ :mobile-cards="true">
+ <b-table-column
+ v-slot="props"
+ field="identifier"
+ label="Link"
+ centered>
+ <a
+ :href="`${config.URL}/a/${props.row.identifier}`"
+ target="_blank">
+ {{ props.row.identifier }}
+ </a>
+ </b-table-column>
+
+ <b-table-column
+ v-slot="props"
+ field="views"
+ label="Views"
+ centered>
+ {{ props.row.views }}
+ </b-table-column>
+
+ <b-table-column
+ v-slot="props"
+ field="enableDownload"
+ label="Allow download"
+ centered>
+ <b-switch
+ v-model="props.row.enableDownload"
+ @input="updateLinkOptions(albumId, props.row)" />
+ </b-table-column>
+
+ <b-table-column
+ v-slot="props"
+ field="enabled"
+ numeric>
+ <button
+ :class="{ 'is-loading': isDeleting(props.row.identifier) }"
+ class="button is-danger"
+ :disabled="isDeleting(props.row.identifier)"
+ @click="promptDeleteAlbumLink(albumId, props.row.identifier)">
+ Delete link
+ </button>
+ </b-table-column>
+
+ <template slot="empty">
+ <div class="has-text-centered">
+ <i class="icon-misc-mood-sad" />
+ </div>
+ <div class="has-text-centered">
+ Nothing here
+ </div>
+ </template>
+
+ <template slot="footer">
+ <div class="level is-paddingless">
+ <div class="level-left">
+ <div class="level-item">
+ <b-field v-if="auth.user.isAdmin">
+ <p class="control">
+ <button
+ :class="{ 'is-loading': isCreatingLink }"
+ class="button is-primary reset-font-size-button"
+ style="float: left"
+ @click="createLink(albumId)">
+ Create new link
+ </button>
+ </p>
+ <p class="control">
+ <b-dropdown>
+ <button slot="trigger" class="button is-primary reset-font-size-button">
+ <b-icon icon="menu-down" />
+ </button>
+
+ <b-dropdown-item @click="createCustomLink(albumId)">
+ Custom link
+ </b-dropdown-item>
+ </b-dropdown>
+ </p>
+ </b-field>
+ <button
+ v-else
+ :class="{ 'is-loading': isCreatingLink }"
+ class="button is-primary"
+ style="float: left"
+ @click="createLink(albumId)">
+ Create new link
+ </button>
+ </div>
+ <div class="level-item">
+ <span class="has-text-default">{{ details.links.length }} / {{ config.maxLinksPerAlbum }} links created</span>
+ </div>
+ </div>
+
+ <div class="level-right">
+ <div class="level-item">
+ <b-switch
+ :value="nsfw"
+ @input="toggleNsfw()" />
+ </div>
+ <div class="level-item">
+ <button
+ class="button is-danger"
+ style="float: right"
+ @click="promptDeleteAlbum(albumId)">
+ Delete album
+ </button>
+ </div>
+ </div>
+ </div>
+ </template>
+ </b-table>
+ </div>
+</template>
+
+<script>
+import { mapState, mapActions } from 'vuex';
+
+export default {
+ props: {
+ albumId: {
+ 'type': Number,
+ 'default': 0
+ },
+ details: {
+ 'type': Object,
+ 'default': () => ({})
+ },
+ nsfw: {
+ 'type': Boolean,
+ 'default': false
+ }
+ },
+ data() {
+ return {
+ isCreatingLink: false,
+ isDeletingLinks: []
+ };
+ },
+ computed: {
+ ...mapState(['config', 'auth'])
+ },
+ mounted() {
+ console.log(this.isNsfw);
+ },
+ methods: {
+ ...mapActions({
+ deleteAlbumAction: 'albums/deleteAlbum',
+ deleteAlbumLinkAction: 'albums/deleteLink',
+ updateLinkOptionsAction: 'albums/updateLinkOptions',
+ createLinkAction: 'albums/createLink',
+ createCustomLinkAction: 'albums/createCustomLink',
+ toggleNsfwAction: 'albums/toggleNsfw',
+ alert: 'alert/set'
+ }),
+ promptDeleteAlbum(id) {
+ this.$buefy.dialog.confirm({
+ type: 'is-danger',
+ message: 'Are you sure you want to delete this album?',
+ onConfirm: () => this.deleteAlbum(id)
+ });
+ },
+ promptDeleteAlbumLink(albumId, identifier) {
+ this.$buefy.dialog.confirm({
+ type: 'is-danger',
+ message: 'Are you sure you want to delete this album link?',
+ onConfirm: () => this.deleteAlbumLink(albumId, identifier)
+ });
+ },
+ async deleteAlbum(id) {
+ try {
+ const response = await this.deleteAlbumAction(id);
+
+ this.alert({ text: response.message, error: false });
+ } catch (e) {
+ this.alert({ text: e.message, error: true });
+ }
+ },
+ async deleteAlbumLink(albumId, identifier) {
+ this.isDeletingLinks.push(identifier);
+ try {
+ const response = await this.deleteAlbumLinkAction({ albumId, identifier });
+
+ this.alert({ text: response.message, error: false });
+ } catch (e) {
+ this.alert({ text: e.message, error: true });
+ } finally {
+ this.isDeletingLinks = this.isDeletingLinks.filter(e => e !== identifier);
+ }
+ },
+ async createLink(albumId) {
+ this.isCreatingLink = true;
+ try {
+ const response = await this.createLinkAction(albumId);
+
+ this.alert({ text: response.message, error: false });
+ } catch (e) {
+ this.alert({ text: e.message, error: true });
+ } finally {
+ this.isCreatingLink = false;
+ }
+ },
+ async updateLinkOptions(albumId, linkOpts) {
+ try {
+ const response = await this.updateLinkOptionsAction({ albumId, linkOpts });
+
+ this.alert({ text: response.message, error: false });
+ } catch (e) {
+ this.alert({ text: e.message, error: true });
+ }
+ },
+ async toggleNsfw() {
+ try {
+ const response = await this.toggleNsfwAction({
+ albumId: this.albumId,
+ nsfw: !this.nsfw
+ });
+ this.alert({ text: response.message, error: false });
+ } catch (e) {
+ this.alert({ text: e.message, error: true });
+ }
+ },
+ async createCustomLink(albumId) {
+ this.$buefy.dialog.prompt({
+ message: 'Custom link identifier',
+ inputAttrs: {
+ placeholder: '',
+ maxlength: 10
+ },
+ trapFocus: true,
+ onConfirm: value => this.$handler.executeAction('albums/createCustomLink', { albumId, value })
+ });
+ },
+ isDeleting(identifier) {
+ return this.isDeletingLinks.indexOf(identifier) > -1;
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+
+ .reset-font-size-button {
+ font-size: 1rem;
+ height: 2.25em;
+ }
+
+ div.details {
+ flex: 0 1 100%;
+ padding-left: 2em;
+ padding-top: 1em;
+ min-height: 50px;
+
+ .b-table {
+ padding: 2em 0em;
+
+ .table-wrapper {
+ box-shadow: $boxShadowLight;
+ }
+ }
+ }
+</style>
+
+<style lang="scss">
+ @import '~/assets/styles/_colors.scss';
+
+ .b-table {
+ .table-wrapper {
+ box-shadow: $boxShadowLight;
+ }
+ }
+
+ .dialog.modal .modal-card-body input {
+ border: 2px solid #21252d;
+ border-radius: 0.3em !important;
+ background: rgba(0, 0, 0, 0.15);
+ padding: 1rem;
+ color: $textColor;
+ height: 3rem;
+ &:focus,
+ &:hover {
+ border: 2px solid #21252d;
+ }
+ &::placeholder {
+ color: $textColor;
+ }
+ }
+</style>
diff --git a/src/site/components/album/AlbumEntry.vue b/src/site/components/album/AlbumEntry.vue
new file mode 100644
index 0000000..8947fa5
--- /dev/null
+++ b/src/site/components/album/AlbumEntry.vue
@@ -0,0 +1,182 @@
+<template>
+ <div class="album">
+ <div
+ class="arrow-container"
+ @click="toggleDetails(album)">
+ <i
+ :class="{ active: isExpanded }"
+ class="icon-arrow" />
+ </div>
+ <div class="thumb">
+ <figure class="image is-64x64 thumb">
+ <img src="~/assets/images/blank_darker.png">
+ </figure>
+ </div>
+ <div class="info">
+ <h4>
+ <router-link :to="`/dashboard/albums/${album.id}`">
+ {{ album.name }}
+ </router-link>
+ </h4>
+ <span>
+ Created <span class="is-inline has-text-weight-semibold"><timeago :since="album.createdAt" /></span>
+ </span>
+ <span>{{ album.fileCount || 0 }} files</span>
+ </div>
+ <div class="latest is-hidden-mobile">
+ <template v-if="album.fileCount > 0">
+ <div
+ v-for="file of album.files"
+ :key="file.id"
+ class="thumb">
+ <figure class="image is-64x64">
+ <a
+ :href="file.url"
+ target="_blank">
+ <img :src="file.thumbSquare">
+ </a>
+ </figure>
+ </div>
+ <div
+ v-if="album.fileCount > 5"
+ class="thumb more no-background">
+ <router-link :to="`/dashboard/albums/${album.id}`">
+ {{ album.fileCount - 5 }}+ more
+ </router-link>
+ </div>
+ </template>
+ <template v-else>
+ <span class="no-files">Nothing to show here</span>
+ </template>
+ </div>
+
+ <AlbumDetails
+ v-if="isExpanded"
+ :details="getDetails(album.id)"
+ :album-id="album.id"
+ :nsfw="album.nsfw" />
+ </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import AlbumDetails from '~/components/album/AlbumDetails.vue';
+
+export default {
+ components: {
+ AlbumDetails
+ },
+ props: {
+ album: {
+ 'type': Object,
+ 'default': () => ({})
+ }
+ },
+ computed: {
+ ...mapGetters({
+ isExpandedGetter: 'albums/isExpanded',
+ getDetails: 'albums/getDetails'
+ }),
+ isExpanded() {
+ return this.isExpandedGetter(this.album.id);
+ }
+ },
+ methods: {
+ async toggleDetails(album) {
+ if (!this.isExpanded) {
+ await this.$store.dispatch('albums/fetchDetails', album.id);
+ }
+ this.$store.commit('albums/toggleExpandedState', album.id);
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+
+ div.album {
+ display: flex;
+ flex-wrap: wrap;
+ margin-bottom: 10px;
+
+ div.arrow-container {
+ width: 2em;
+ height: 64px;
+ position: relative;
+ cursor: pointer;
+
+ i {
+ border: 2px solid $defaultTextColor;
+ border-right: 0;
+ border-top: 0;
+ display: block;
+ height: 1em;
+ position: absolute;
+ transform: rotate(-135deg);
+ transform-origin: center;
+ width: 1em;
+ z-index: 4;
+ top: 22px;
+ transition: transform 0.1s linear;
+
+ &.active {
+ transform: rotate(-45deg);
+ }
+ }
+ }
+
+ div.thumb {
+ width: 64px;
+ height: 64px;
+ box-shadow: $boxShadowLight;
+ }
+
+ div.info {
+ margin-left: 15px;
+ text-align: left;
+ h4 {
+ font-size: 1.5rem;
+ a {
+ color: $defaultTextColor;
+ font-weight: 400;
+ &:hover { text-decoration: underline; }
+ }
+ }
+ span { display: block; }
+ span:nth-child(3) {
+ font-size: 0.9rem;
+ }
+ }
+
+ div.latest {
+ flex-grow: 1;
+ justify-content: flex-end;
+ display: flex;
+ margin-left: 15px;
+
+ span.no-files {
+ font-size: 1.5em;
+ color: #b1b1b1;
+ padding-top: 17px;
+ }
+
+ div.more {
+ width: 64px;
+ height: 64px;
+ background: white;
+ display: flex;
+ align-items: center;
+ padding: 10px;
+ text-align: center;
+ a {
+ line-height: 1rem;
+ color: $defaultTextColor;
+ &:hover { text-decoration: underline; }
+ }
+ }
+ }
+ }
+
+ div.no-background { background: none !important; }
+</style>
diff --git a/src/site/components/footer/Footer.test.js b/src/site/components/footer/Footer.test.js
new file mode 100644
index 0000000..379f939
--- /dev/null
+++ b/src/site/components/footer/Footer.test.js
@@ -0,0 +1,25 @@
+/* eslint-disable no-undef */
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Component from './Footer.vue';
+import Vuex from 'vuex';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Footer.vue', () => {
+ const store = new Vuex.Store({
+ getters: {
+ 'auth/isLoggedIn': () => false
+ },
+ state: {
+ config: {}
+ }
+ });
+
+ it('Should render chibisafe as the instance title', () => {
+ const wrapper = shallowMount(Component, { store, localVue });
+
+ const title = wrapper.find('h4');
+ expect(title.text()).toBe('chibisafe');
+ });
+});
diff --git a/src/site/components/footer/Footer.vue b/src/site/components/footer/Footer.vue
new file mode 100644
index 0000000..38e3f07
--- /dev/null
+++ b/src/site/components/footer/Footer.vue
@@ -0,0 +1,110 @@
+<template>
+ <footer>
+ <div class="container">
+ <div class="columns">
+ <div class="column is-narrow">
+ <h4>chibisafe</h4>
+ <span>© 2017-{{ getYear }}
+ <a
+ href="https://github.com/pitu"
+ class="no-block">Pitu</a>
+ </span><br>
+ <span>v{{ version }}</span>
+ </div>
+ <div class="column is-narrow bottom-up">
+ <a href="https://github.com/weebdev/chibisafe">GitHub</a>
+ <a href="https://patreon.com/pitu">Patreon</a>
+ <a href="https://discord.gg/5g6vgwn">Discord</a>
+ </div>
+ <div class="column is-narrow bottom-up">
+ <a
+ v-if="loggedIn"
+ @click="createShareXThing">ShareX Config</a>
+ <a href="https://chrome.google.com/webstore/detail/lolisafe-uploader/enkkmplljfjppcdaancckgilmgoiofnj">Chrome Extension</a>
+ </div>
+ </div>
+ </div>
+ </footer>
+</template>
+
+<script>
+/* eslint-disable no-restricted-globals */
+
+import { mapState, mapGetters } from 'vuex';
+import { saveAs } from 'file-saver';
+
+export default {
+ computed: {
+ ...mapGetters({ loggedIn: 'auth/isLoggedIn' }),
+ ...mapState({
+ version: state => state.config.version,
+ serviceName: state => state.config.serviceName,
+ token: state => state.auth.token
+ }),
+ getYear() {
+ return new Date().getFullYear();
+ }
+ },
+ methods: {
+ createShareXThing() {
+ const sharexFile = `{
+ "Name": "${this.serviceName}",
+ "DestinationType": "ImageUploader, FileUploader",
+ "RequestType": "POST",
+ "RequestURL": "${location.origin}/api/upload",
+ "FileFormName": "files[]",
+ "Headers": {
+ "authorization": "Bearer ${this.token}",
+ "accept": "application/vnd.chibisafe.json"
+ },
+ "ResponseType": "Text",
+ "URL": "$json:url$",
+ "ThumbnailURL": "$json:url$"
+ }`;
+ const sharexBlob = new Blob([sharexFile], { type: 'application/octet-binary' });
+ saveAs(sharexBlob, `${location.hostname}.sxcu`);
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+ footer {
+ @media screen and (min-width: 1025px) {
+ position: fixed;
+ bottom: 0;
+ width: 100%;
+ > div {
+ padding: 1rem 1rem !important;
+ max-width: unset !important;
+ }
+ }
+
+ .container {
+ .column {
+ text-align: center;
+ @media screen and (min-width: 1025px) {
+ margin-right: 2rem;
+ &.bottom-up {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ margin-right: 0;
+ }
+ }
+
+ a {
+ display: block;
+ color: $textColor;
+ &:hover {
+ color: white
+ }
+ &.no-block {
+ display: inherit;
+ }
+ }
+ }
+ }
+ }
+</style>
diff --git a/src/site/components/grid/Grid.vue b/src/site/components/grid/Grid.vue
new file mode 100644
index 0000000..9e1ce6f
--- /dev/null
+++ b/src/site/components/grid/Grid.vue
@@ -0,0 +1,498 @@
+<template>
+ <div>
+ <nav class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <slot name="pagination" />
+ </div>
+ </div>
+ <!-- TODO: Externalize this so it can be saved as an user config (and between re-renders) -->
+ <div v-if="enableToolbar" class="level-right toolbar">
+ <div class="level-item">
+ <div class="block">
+ <b-radio v-model="showList" name="name" :native-value="true">
+ List
+ </b-radio>
+ <b-radio v-model="showList" name="name" :native-value="false">
+ Grid
+ </b-radio>
+ </div>
+ </div>
+ </div>
+ </nav>
+
+ <template v-if="!images.showList">
+ <Waterfall
+ :gutter-width="10"
+ :gutter-height="4"
+ :options="{fitWidth: true}"
+ :item-width="width"
+ :items="gridFiles">
+ <template v-slot="{item}">
+ <template v-if="isPublic">
+ <a
+ :href="`${item.url}`"
+ class="preview-container"
+ target="_blank"
+ @mouseenter.self.stop.prevent="item.preview && mouseOver(item.id)"
+ @mouseleave.self.stop.prevent="item.preview && mouseOut(item.id)">
+
+ <img :src="item.thumb ? item.thumb : blank">
+ <div v-if="item.preview && isHovered(item.id)" class="preview">
+ <video ref="video" class="preview" autoplay loop muted>
+ <source :src="item.preview" type="video/mp4">
+ </video>
+ </div>
+
+ <span v-if="!item.thumb && item.name" class="extension">{{
+ item.name.split('.').pop()
+ }}</span>
+ </a>
+ </template>
+ <template v-else>
+ <img :src="item.thumb ? item.thumb : blank">
+ <div v-if="item.preview && isHovered(item.id)" class="preview">
+ <video ref="video" class="preview" autoplay loop muted>
+ <source :src="item.preview" type="video/mp4">
+ </video>
+ </div>
+
+ <span v-if="!item.thumb && item.name" class="extension">{{ item.name.split('.').pop() }}</span>
+ <div
+ v-if="!isPublic"
+ :class="{ fixed }"
+ class="actions"
+ @mouseenter.self.stop.prevent="item.preview && mouseOver(item.id)"
+ @mouseleave.self.stop.prevent="item.preview && mouseOut(item.id)">
+ <b-tooltip label="Link" position="is-top">
+ <a :href="`${item.url}`" target="_blank" class="btn">
+ <i class="mdi mdi-open-in-new" />
+ </a>
+ </b-tooltip>
+ <b-tooltip label="Edit" position="is-top">
+ <a class="btn" @click="handleFileModal(item)">
+ <i class="mdi mdi-pencil" />
+ </a>
+ </b-tooltip>
+ <b-tooltip label="Delete" position="is-top">
+ <a class="btn" @click="deleteFile(item)">
+ <i class="mdi mdi-delete" />
+ </a>
+ </b-tooltip>
+ <b-tooltip v-if="user && user.isAdmin" label="More info" position="is-top" class="more">
+ <nuxt-link :to="`/dashboard/admin/file/${item.id}`">
+ <i class="mdi mdi-dots-horizontal" />
+ </nuxt-link>
+ </b-tooltip>
+ </div>
+ </template>
+ </template>
+ </Waterfall>
+ </template>
+ <div v-else>
+ <b-table :data="gridFiles || []" :mobile-cards="true">
+ <b-table-column v-slot="props" field="url" label="URL">
+ <a :href="props.row.url" target="_blank">{{ props.row.url }}</a>
+ </b-table-column>
+
+ <b-table-column v-slot="props" field="albums" label="Albums" centered>
+ <template v-for="(album, index) in props.row.albums">
+ <nuxt-link :key="index" :to="`/dashboard/albums/${album.id}`">
+ {{ album.name }}
+ </nuxt-link>
+ <template v-if="index < props.row.albums.length - 1">
+ ,
+ </template>
+ </template>
+
+ {{ props.row.username }}
+ </b-table-column>
+
+ <b-table-column v-slot="props" field="uploaded" label="Uploaded" centered>
+ <span><timeago :since="props.row.createdAt" /></span>
+ </b-table-column>
+
+ <b-table-column v-slot="props" field="purge" centered>
+ <b-tooltip label="Edit" position="is-top">
+ <a class="btn" @click="handleFileModal(props.row)">
+ <i class="mdi mdi-pencil" />
+ </a>
+ </b-tooltip>
+ <b-tooltip label="Delete" position="is-top" class="is-danger">
+ <a class="is-danger" @click="deleteFile(props.row)">
+ <i class="mdi mdi-delete" />
+ </a>
+ </b-tooltip>
+ <b-tooltip v-if="user && user.isAdmin" label="More info" position="is-top" class="more">
+ <nuxt-link :to="`/dashboard/admin/file/${props.row.id}`">
+ <i class="mdi mdi-dots-horizontal" />
+ </nuxt-link>
+ </b-tooltip>
+ </b-table-column>
+
+ <template slot="empty">
+ <div class="has-text-centered">
+ <i class="icon-misc-mood-sad" />
+ </div>
+ <div class="has-text-centered">
+ Nothing here
+ </div>
+ </template>
+ <template slot="footer">
+ <div class="has-text-right has-text-default">
+ Showing {{ files.length }} files ({{ total }} total)
+ </div>
+ </template>
+ </b-table>
+ </div>
+
+ <b-modal class="imageinfo-modal" :active.sync="isAlbumsModalActive">
+ <ImageInfo :file="modalData.file" :albums="modalData.albums" :tags="modalData.tags" />
+ </b-modal>
+ </div>
+</template>
+
+<script>
+import { mapState } from 'vuex';
+
+import Waterfall from './waterfall/Waterfall.vue';
+import ImageInfo from '~/components/image-modal/ImageInfo.vue';
+
+export default {
+ components: {
+ Waterfall,
+ ImageInfo
+ },
+ props: {
+ files: {
+ 'type': Array,
+ 'default': () => []
+ },
+ total: {
+ 'type': Number,
+ 'default': 0
+ },
+ fixed: {
+ 'type': Boolean,
+ 'default': false
+ },
+ isPublic: {
+ 'type': Boolean,
+ 'default': false
+ },
+ width: {
+ 'type': Number,
+ 'default': 150
+ },
+ enableSearch: {
+ 'type': Boolean,
+ 'default': true
+ },
+ enableToolbar: {
+ 'type': Boolean,
+ 'default': true
+ }
+ },
+ data() {
+ return {
+ searchTerm: null,
+ showList: false,
+ hoveredItems: [],
+ isAlbumsModalActive: false,
+ showingModalForFile: null,
+ filesOffsetWaterfall: 0,
+ filesOffsetEndWaterfall: 50,
+ filesPerPageWaterfall: 50,
+ modalData: {
+ file: null,
+ tags: null,
+ albums: null
+ }
+ };
+ },
+ computed: {
+ ...mapState({
+ user: state => state.auth.user,
+ albums: state => state.albums.tinyDetails,
+ images: state => state.images
+ }),
+ blank() {
+ return require('@/assets/images/blank.png');
+ },
+ gridFiles() {
+ return (this.files || []).filter(v => !v.hideFromList);
+ }
+ },
+ watch: {
+ showList: 'displayTypeChange'
+ },
+ created() {
+ // TODO: Create a middleware for this
+ this.getAlbums();
+ this.getTags();
+
+ this.showList = this.images.showList;
+ },
+ methods: {
+ async search() {
+ const data = await this.$search.do(this.searchTerm, ['name', 'original', 'type', 'albums:name']);
+ console.log('> Search result data', data); // eslint-disable-line no-console
+ },
+ deleteFile(file) {
+ // this.$emit('delete', file);
+ this.$buefy.dialog.confirm({
+ title: 'Deleting file',
+ message: 'Are you sure you want to <b>delete</b> this file?',
+ confirmText: 'Delete File',
+ type: 'is-danger',
+ onConfirm: async () => {
+ try {
+ const response = await this.$store.dispatch('images/deleteFile', file.id);
+
+ this.$buefy.toast.open(response.message);
+ } catch (e) {
+ this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+ }
+ });
+ },
+ isAlbumSelected(id) {
+ if (!this.showingModalForFile) return false;
+ const found = this.showingModalForFile.albums.find(el => el.id === id);
+ return Boolean(found && found.id);
+ },
+ async openAlbumModal(file) {
+ const { id } = file;
+ this.showingModalForFile = file;
+ this.showingModalForFile.albums = [];
+
+ try {
+ await this.$store.dispatch('images/getFileAlbums', id);
+ } catch (e) {
+ this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+ this.showingModalForFile.albums = this.images.fileAlbumsMap[id];
+
+ this.isAlbumsModalActive = true;
+ },
+ async albumCheckboxClicked(add, id) {
+ try {
+ let response;
+ if (add) {
+ response = await this.$store.dispatch('images/addToAlbum', {
+ albumId: id,
+ fileId: this.showingModalForFile.id
+ });
+ } else {
+ response = await this.$store.dispatch('images/removeFromAlbum', {
+ albumId: id,
+ fileId: this.showingModalForFile.id
+ });
+ }
+
+ this.$buefy.toast.open(response.message);
+ } catch (e) {
+ this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+ },
+ async getAlbums() {
+ try {
+ await this.$store.dispatch('albums/getTinyDetails');
+ } catch (e) {
+ this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+ },
+ async handleFileModal(file) {
+ const { id } = file;
+
+ try {
+ await this.$store.dispatch('images/fetchFileMeta', id);
+ this.modalData.file = this.images.fileExtraInfoMap[id];
+ this.modalData.albums = this.images.fileAlbumsMap[id];
+ this.modalData.tags = this.images.fileTagsMap[id];
+ } catch (e) {
+ this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+
+ this.isAlbumsModalActive = true;
+ },
+ async getTags() {
+ try {
+ await this.$store.dispatch('tags/fetch');
+ } catch (e) {
+ this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+ },
+ mouseOver(id) {
+ const foundIndex = this.hoveredItems.indexOf(id);
+ if (foundIndex > -1) return;
+ this.hoveredItems.push(id);
+ },
+ mouseOut(id) {
+ const foundIndex = this.hoveredItems.indexOf(id);
+ if (foundIndex > -1) this.hoveredItems.splice(foundIndex, 1);
+ },
+ isHovered(id) {
+ return this.hoveredItems.includes(id);
+ },
+ displayTypeChange(showList) {
+ this.$store.commit('images/setShowList', showList);
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+@import '~/assets/styles/_colors.scss';
+.item-move {
+ transition: all 0.25s cubic-bezier(0.55, 0, 0.1, 1);
+}
+
+div.toolbar {
+ padding: 1rem;
+
+ .block {
+ text-align: right;
+ }
+}
+
+span.extension {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ z-index: 0;
+ top: 0;
+ left: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 2rem;
+ pointer-events: none;
+ opacity: 0.75;
+ max-width: 150px;
+}
+
+div.preview {
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ width: 100%;
+ height: calc(100% - 6px);
+ overflow: hidden;
+}
+
+.preview-container {
+ display: inline-block;
+}
+
+div.actions {
+ opacity: 0;
+ transition: opacity 0.1s linear;
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ width: 100%;
+ height: calc(100% - 6px);
+ // background: rgba(0, 0, 0, 0.5);
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.5) 0px, rgba(0, 0, 0, 0) 60px),
+ linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0px, rgba(0, 0, 0, 0) 45px);
+ display: flex;
+ justify-content: center;
+ align-items: flex-end;
+
+ span {
+ padding: 3px;
+
+ &.more {
+ position: absolute;
+ top: 0;
+ right: 0;
+ }
+
+ &:nth-child(1),
+ &:nth-child(2) {
+ align-items: flex-end;
+ padding-bottom: 10px;
+ }
+
+ &:nth-child(3),
+ &:nth-child(4) {
+ justify-content: flex-end;
+ padding-bottom: 10px;
+ }
+
+ a {
+ width: 30px;
+ height: 30px;
+ color: white;
+ justify-content: center;
+ align-items: center;
+ display: flex;
+ &.btn:before {
+ content: '';
+ width: 30px;
+ height: 30px;
+ border: 1px solid white;
+ border-radius: 50%;
+ position: absolute;
+ }
+ }
+ }
+
+ &.fixed {
+ position: relative;
+ opacity: 1;
+ background: none;
+
+ a {
+ width: auto;
+ height: auto;
+ color: $defaultTextColor;
+ &:before {
+ display: none;
+ }
+ }
+ }
+}
+
+.albums-container {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ .album {
+ flex-basis: 33%;
+ text-align: left;
+ }
+}
+
+.hidden {
+ display: none;
+}
+
+.waterfall {
+ margin: 0 auto;
+}
+
+.waterfall-item:hover {
+ div.actions {
+ opacity: 1;
+ }
+}
+
+.imageinfo-modal::-webkit-scrollbar {
+ width: 0px; /* Remove scrollbar space */
+ background: transparent; /* Optional: just make scrollbar invisible */
+}
+
+i.mdi {
+ font-size: 16px;
+}
+
+.imageinfo-modal{
+ ::v-deep .modal-content {
+ @media screen and (max-width: 768px) {
+ min-height: 100vh;
+ }
+ }
+}
+</style>
diff --git a/src/site/components/grid/waterfall/Waterfall.vue b/src/site/components/grid/waterfall/Waterfall.vue
new file mode 100644
index 0000000..5a4c569
--- /dev/null
+++ b/src/site/components/grid/waterfall/Waterfall.vue
@@ -0,0 +1,129 @@
+<template>
+ <div ref="waterfall" class="waterfall">
+ <WaterfallItem
+ v-for="item in items"
+ :key="item.id"
+ :style="{ width: `${itemWidth}px`, marginBottom: `${gutterHeight}px` }"
+ :width="itemWidth">
+ <slot :item="item" />
+ </WaterfallItem>
+ </div>
+</template>
+<script>
+import WaterfallItem from './WaterfallItem.vue';
+
+const isBrowser = typeof window !== 'undefined';
+// eslint-disable-next-line global-require
+const Masonry = isBrowser ? window.Masonry || require('masonry-layout') : null;
+const imagesloaded = isBrowser ? require('imagesloaded') : null;
+
+export default {
+ name: 'Waterfall',
+ components: {
+ WaterfallItem
+ },
+ props: {
+ options: {
+ 'type': Object,
+ 'default': () => {}
+ },
+ items: {
+ 'type': Array,
+ 'default': () => []
+ },
+ itemWidth: {
+ 'type': Number,
+ 'default': 150
+ },
+ gutterWidth: {
+ 'type': Number,
+ 'default': 10
+ },
+ gutterHeight: {
+ 'type': Number,
+ 'default': 4
+ }
+ },
+ mounted() {
+ this.initializeMasonry();
+ this.imagesLoaded();
+ },
+ updated() {
+ this.performLayout();
+ this.imagesLoaded();
+ },
+ unmounted() {
+ this.masonry.destroy();
+ },
+ methods: {
+ imagesLoaded() {
+ const node = this.$refs.waterfall;
+ imagesloaded(
+ node,
+ () => {
+ this.masonry.layout();
+ }
+ );
+ },
+ performLayout() {
+ const diff = this.diffDomChildren();
+ if (diff.removed.length > 0) {
+ this.masonry.remove(diff.removed);
+ this.masonry.reloadItems();
+ }
+ if (diff.appended.length > 0) {
+ this.masonry.appended(diff.appended);
+ this.masonry.reloadItems();
+ }
+ if (diff.prepended.length > 0) {
+ this.masonry.prepended(diff.prepended);
+ }
+ if (diff.moved.length > 0) {
+ this.masonry.reloadItems();
+ }
+ this.masonry.layout();
+ },
+ diffDomChildren() {
+ const oldChildren = this.domChildren.filter(element => Boolean(element.parentNode));
+ const newChildren = this.getNewDomChildren();
+ const removed = oldChildren.filter(oldChild => !newChildren.includes(oldChild));
+ const domDiff = newChildren.filter(newChild => !oldChildren.includes(newChild));
+ const prepended = domDiff.filter((newChild, index) => newChildren[index] === newChild);
+ const appended = domDiff.filter(el => !prepended.includes(el));
+ let moved = [];
+ if (removed.length === 0) {
+ moved = oldChildren.filter((child, index) => index !== newChildren.indexOf(child));
+ }
+ this.domChildren = newChildren;
+ return {
+ 'old': oldChildren,
+ 'new': newChildren,
+ removed,
+ appended,
+ prepended,
+ moved
+ };
+ },
+ initializeMasonry() {
+ if (!this.masonry) {
+ this.masonry = new Masonry(
+ this.$refs.waterfall,
+ {
+ columnWidth: this.itemWidth,
+ gutter: this.gutterWidth,
+ ...this.options
+ }
+ );
+ this.domChildren = this.getNewDomChildren();
+ }
+ },
+ getNewDomChildren() {
+ const node = this.$refs.waterfall;
+ const children = this.options && this.options.itemSelector
+ ? node.querySelectorAll(this.options.itemSelector)
+ : node.children;
+ return Array.prototype.slice.call(children);
+ }
+ }
+};
+</script>
diff --git a/src/site/components/grid/waterfall/WaterfallItem.vue b/src/site/components/grid/waterfall/WaterfallItem.vue
new file mode 100644
index 0000000..2a18606
--- /dev/null
+++ b/src/site/components/grid/waterfall/WaterfallItem.vue
@@ -0,0 +1,10 @@
+<template>
+ <div class="waterfall-item">
+ <slot />
+ </div>
+</template>
+<script>
+export default {
+ name: 'WaterfallItem'
+};
+</script>
diff --git a/src/site/components/home/links/Links.vue b/src/site/components/home/links/Links.vue
new file mode 100644
index 0000000..05915b9
--- /dev/null
+++ b/src/site/components/home/links/Links.vue
@@ -0,0 +1,142 @@
+<template>
+ <div class="links">
+ <a
+ href="https://github.com/WeebDev/chibisafe"
+ target="_blank"
+ class="link">
+ <header class="bd-footer-star-header">
+ <h4 class="bd-footer-title">GitHub</h4>
+ <p class="bd-footer-subtitle">Deploy your own chibisafe</p>
+ </header>
+ </a>
+ <div
+ v-if="loggedIn"
+ class="link"
+ @click="createShareXThing">
+ <header class="bd-footer-star-header">
+ <h4 class="bd-footer-title">
+ ShareX
+ </h4>
+ <p class="bd-footer-subtitle">
+ Upload from your Desktop
+ </p>
+ </header>
+ </div>
+ <a
+ href="https://chrome.google.com/webstore/detail/lolisafe-uploader/enkkmplljfjppcdaancckgilmgoiofnj"
+ target="_blank"
+ class="link">
+ <header class="bd-footer-star-header">
+ <h4 class="bd-footer-title">Extension</h4>
+ <p class="bd-footer-subtitle">Upload from any website</p>
+ </header>
+ </a>
+ <router-link
+ to="/faq"
+ class="link">
+ <header class="bd-footer-star-header">
+ <h4 class="bd-footer-title">
+ FAQ
+ </h4>
+ <p class="bd-footer-subtitle">
+ We got you covered
+ </p>
+ </header>
+ </router-link>
+ </div>
+</template>
+<script>
+import { saveAs } from 'file-saver';
+
+export default {
+ computed: {
+ loggedIn() {
+ return this.$store.state.auth.loggedIn;
+ }
+ },
+ methods: {
+ createShareXThing() {
+ const sharexFile = `{
+ "Name": "${this.$store.state.config.serviceName}",
+ "DestinationType": "ImageUploader, FileUploader",
+ "RequestType": "POST",
+ "RequestURL": "${location.origin}/api/upload",
+ "FileFormName": "files[]",
+ "Headers": {
+ "authorization": "Bearer ${this.$store.state.token}",
+ "accept": "application/vnd.chibisafe.json"
+ },
+ "ResponseType": "Text",
+ "URL": "$json:url$",
+ "ThumbnailURL": "$json:url$"
+ }`;
+ const sharexBlob = new Blob([sharexFile], { type: 'application/octet-binary' });
+ saveAs(sharexBlob, `${location.hostname}.sxcu`);
+ }
+ }
+};
+</script>
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+ .links {
+ margin: 7rem 0 3rem 0;
+ align-items: stretch;
+ display: flex;
+ justify-content: space-between;
+
+ div.link { cursor: pointer; }
+ .link {
+ background: #0000002e;
+ border: 1px solid #00000061;
+ display: block;
+ width: calc(25% - 2rem);
+ border-radius: 6px;
+ box-shadow: 0 1.5rem 1.5rem -1.25rem rgba(10,10,10,.05);
+ transition-duration: 86ms;
+ transition-property: box-shadow,-webkit-transform;
+ transition-property: box-shadow,transform;
+ transition-property: box-shadow,transform,-webkit-transform;
+ will-change: box-shadow,transform;
+
+ header.bd-footer-star-header {
+ padding: 1.5rem;
+
+ &:hover .bd-footer-subtitle { color: $textColorHighlight; }
+
+ h4.bd-footer-title {
+ color: $textColorHighlight;
+ font-size: 1.5rem;
+ line-height: 1.25;
+ margin-bottom: .5rem;
+ transition-duration: 86ms;
+ transition-property: color;
+ font-weight: 700;
+ }
+
+ p.bd-footer-subtitle {
+ color: $textColor;
+ margin-top: -.5rem;
+ transition-duration: 86ms;
+ transition-property: color;
+ font-weight: 400;
+ }
+ }
+
+ &:hover {
+ box-shadow: 0 3rem 3rem -1.25rem rgba(10,10,10,.1);
+ transform: translateY(-.5rem);
+ }
+ }
+ }
+
+ @media screen and (max-width: 768px) {
+ .links {
+ display: block;
+ padding: 0px 2em;
+ .link {
+ width: 100%;
+ margin-bottom: 1.5em;
+ }
+ }
+ }
+</style>
diff --git a/src/site/components/image-modal/AlbumInfo.vue b/src/site/components/image-modal/AlbumInfo.vue
new file mode 100644
index 0000000..8aeb02e
--- /dev/null
+++ b/src/site/components/image-modal/AlbumInfo.vue
@@ -0,0 +1,90 @@
+<template>
+ <b-dropdown
+ v-model="selectedOptions"
+ multiple
+ expanded
+ scrollable
+ inline
+ aria-role="list"
+ max-height="500px">
+ <button slot="trigger" class="button is-primary" type="button">
+ <span>Albums ({{ selectedOptions.length }})</span>
+ <b-icon icon="menu-down" />
+ </button>
+
+ <b-dropdown-item
+ v-for="album in orderedAlbums"
+ :key="album.id"
+ :value="album.id"
+ aria-role="listitem"
+ @click="handleClick(album.id)">
+ <span>{{ album.name }}</span>
+ </b-dropdown-item>
+ </b-dropdown>
+</template>
+
+<script>
+export default {
+ name: 'Albuminfo',
+ props: {
+ imageId: {
+ 'type': Number,
+ 'default': 0
+ },
+ imageAlbums: {
+ 'type': Array,
+ 'default': () => []
+ },
+ albums: {
+ 'type': Array,
+ 'default': () => []
+ }
+ },
+ data() {
+ return {
+ selectedOptions: [],
+ orderedAlbums: []
+ };
+ },
+ created() {
+ this.orderedAlbums = this.getOrderedAlbums();
+ // we're sorting here instead of computed because we want sort on creation
+ // then the array's values should be frozen
+ this.selectedOptions = this.imageAlbums.map(e => e.id);
+ },
+ methods: {
+ getOrderedAlbums() {
+ return [...this.albums].sort(
+ (a, b) => {
+ const selectedA = this.imageAlbums.findIndex(({ name }) => name === a.name) !== -1;
+ const selectedB = this.imageAlbums.findIndex(({ name }) => name === b.name) !== -1;
+
+ if (selectedA !== selectedB) {
+ return selectedA ? -1 : 1;
+ }
+ return a.name.localeCompare(b.name);
+ }
+ );
+ },
+ isAlbumSelected(id) {
+ if (!this.showingModalForFile) return false;
+ const found = this.showingModalForFile.albums.find(el => el.id === id);
+ return Boolean(found && found.id);
+ },
+ async handleClick(id) {
+ // here the album should be already removed from the selected list
+ if (this.selectedOptions.indexOf(id) > -1) {
+ this.$handler.executeAction('images/addToAlbum', {
+ albumId: id,
+ fileId: this.imageId
+ });
+ } else {
+ this.$handler.executeAction('images/removeFromAlbum', {
+ albumId: id,
+ fileId: this.imageId
+ });
+ }
+ }
+ }
+};
+</script>
diff --git a/src/site/components/image-modal/ImageInfo.vue b/src/site/components/image-modal/ImageInfo.vue
new file mode 100644
index 0000000..fdea285
--- /dev/null
+++ b/src/site/components/image-modal/ImageInfo.vue
@@ -0,0 +1,211 @@
+<template>
+ <div class="container has-background-chibisafe">
+ <div class="columns is-marginless">
+ <div class="column image-col has-centered-items">
+ <img v-if="!isVideo(file.type)" class="col-img" :src="file.url">
+ <video v-else class="col-vid" controls>
+ <source :src="file.url" :type="file.type">
+ </video>
+ </div>
+ <div class="column data-col is-one-third">
+ <div class="sticky">
+ <div class="divider is-chibisafe has-text-light">
+ File information
+ </div>
+ <b-field
+ label="ID"
+ label-position="on-border"
+ type="is-chibisafe"
+ class="chibisafe-on-border">
+ <div class="control">
+ <span class="fake-input">{{ file.id }}</span>
+ </div>
+ </b-field>
+ <b-field
+ label="Name"
+ label-position="on-border"
+ type="is-chibisafe"
+ class="chibisafe-on-border">
+ <div class="control">
+ <span class="fake-input">{{ file.name }}</span>
+ </div>
+ </b-field>
+
+ <b-field
+ label="Original Name"
+ label-position="on-border"
+ type="is-chibisafe"
+ class="chibisafe-on-border">
+ <div class="control">
+ <span class="fake-input">{{ file.original }}</span>
+ </div>
+ </b-field>
+
+ <b-field
+ label="IP"
+ label-position="on-border"
+ type="is-chibisafe"
+ class="chibisafe-on-border">
+ <div class="control">
+ <span class="fake-input">{{ file.ip }}</span>
+ </div>
+ </b-field>
+
+ <b-field
+ label="Link"
+ label-position="on-border"
+ type="is-chibisafe"
+ class="chibisafe-on-border">
+ <div class="control">
+ <a
+ class="fake-input"
+ :href="file.url"
+ target="_blank">{{ file.url }}</a>
+ </div>
+ </b-field>
+
+ <b-field
+ label="Size"
+ label-position="on-border"
+ type="is-chibisafe"
+ class="chibisafe-on-border">
+ <div class="control">
+ <span class="fake-input">{{ formatBytes(file.size) }}</span>
+ </div>
+ </b-field>
+
+ <b-field
+ label="Hash"
+ label-position="on-border"
+ type="is-chibisafe"
+ class="chibisafe-on-border">
+ <div class="control">
+ <span class="fake-input">{{ file.hash }}</span>
+ </div>
+ </b-field>
+
+ <b-field
+ label="Uploaded"
+ label-position="on-border"
+ type="is-chibisafe"
+ class="chibisafe-on-border">
+ <div class="control">
+ <span class="fake-input"><timeago :since="file.createdAt" /></span>
+ </div>
+ </b-field>
+
+ <div class="divider is-chibisafe has-text-light">
+ Tags
+ </div>
+ <Taginfo :image-id="file.id" :image-tags="tags" />
+
+ <div class="divider is-chibisafe has-text-light">
+ Albums
+ </div>
+ <Albuminfo :image-id="file.id" :image-albums="albums" :albums="tinyDetails" />
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import { mapState } from 'vuex';
+
+import Albuminfo from './AlbumInfo.vue';
+import Taginfo from './TagInfo.vue';
+
+export default {
+ components: {
+ Taginfo,
+ Albuminfo
+ },
+ props: {
+ file: {
+ 'type': Object,
+ 'default': () => ({})
+ },
+ albums: {
+ 'type': Array,
+ 'default': () => ([])
+ },
+ tags: {
+ 'type': Array,
+ 'default': () => ([])
+ }
+ },
+ computed: mapState({
+ images: state => state.images,
+ tinyDetails: state => state.albums.tinyDetails
+ }),
+ methods: {
+ formatBytes(bytes, decimals = 2) {
+ if (bytes === 0) return '0 Bytes';
+
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ // eslint-disable-next-line no-mixed-operators
+ return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
+ },
+ isVideo(type) {
+ return type.startsWith('video');
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+@import '~/assets/styles/_colors.scss';
+.modal-content, .modal-card {
+ max-height: 100%;
+}
+
+.fake-input {
+ font-size: 1rem !important;
+ height: 2.5rem;
+ border-color: #323846; /* $chibisafe */
+ max-width: 100%;
+ width: 100%;
+ border-radius: 4px;
+ display: inline-block;
+ font-size: 1rem;
+ justify-content: flex-start;
+ line-height: 1.5;
+ padding-bottom: calc(0.375em - 1px);
+ padding-left: calc(0.625em - 1px);
+ padding-right: calc(0.625em - 1px);
+ padding-top: calc(0.375em - 1px);
+ background-color: #21252d;
+ border: 2px solid #21252d;
+ border-radius: 0.3em !important;
+
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.divider:first-child {
+ margin: 10px 0 25px;
+}
+
+.col-vid {
+ width: 100%;
+}
+
+.image-col {
+ align-items: center;
+}
+
+.data-col {
+ @media screen and (min-width: 769px) {
+ padding-right: 1.5rem;
+ }
+ @media screen and (max-width: 769px) {
+ padding-bottom: 3rem;
+ }
+}
+</style>
diff --git a/src/site/components/image-modal/TagInfo.vue b/src/site/components/image-modal/TagInfo.vue
new file mode 100644
index 0000000..fb65343
--- /dev/null
+++ b/src/site/components/image-modal/TagInfo.vue
@@ -0,0 +1,95 @@
+<template>
+ <b-field label="Add some tags">
+ <b-taginput
+ :value="selectedTags"
+ :data="filteredTags"
+ class="chibisafe taginp"
+ ellipsis
+ icon="label"
+ placeholder="Add a tag"
+ autocomplete
+ allow-new
+ @typing="getFilteredTags"
+ @add="tagAdded"
+ @remove="tagRemoved" />
+ </b-field>
+</template>
+
+<script>
+import { mapState } from 'vuex';
+
+export default {
+ name: 'Taginfo',
+ props: {
+ imageId: {
+ 'type': Number,
+ 'default': 0
+ },
+ imageTags: {
+ 'type': Array,
+ 'default': () => []
+ }
+ },
+ data() {
+ return {
+ filteredTags: []
+ };
+ },
+ computed: {
+ ...mapState({
+ tags: state => state.tags.tagsList
+ }),
+ selectedTags() { return this.imageTags.map(e => e.name); },
+ lowercaseTags() { return this.imageTags.map(e => e.name.toLowerCase()); }
+ },
+ methods: {
+ getFilteredTags(str) {
+ this.filteredTags = this.tags.map(e => e.name).filter(e => {
+ // check if the search string matches any of the tags
+ const sanitezedTag = e.toString().toLowerCase();
+ const matches = sanitezedTag.indexOf(str.toLowerCase()) >= 0;
+
+ // check if this tag is already added to our image, to avoid duplicates
+ if (matches) {
+ const foundIndex = this.lowercaseTags.indexOf(sanitezedTag);
+ if (foundIndex === -1) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+ },
+ async tagAdded(tag) {
+ if (!tag) return;
+
+ // normalize into NFC form (diactirics and moonrunes)
+ // replace all whitespace with _
+ // replace multiple __ with a single one
+ tag = tag.normalize('NFC').replace(/\s/g, '_').replace(/_+/g, '_');
+
+ const foundIndex = this.tags.findIndex(({ name }) => name === tag);
+
+ if (foundIndex === -1) {
+ await this.$handler.executeAction('tags/createTag', tag);
+ }
+
+ await this.$handler.executeAction('images/addTag', { fileId: this.imageId, tagName: tag });
+ },
+ tagRemoved(tag) {
+ this.$handler.executeAction('images/removeTag', { fileId: this.imageId, tagName: tag });
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+@import '~/assets/styles/_colors.scss';
+
+.taginp {
+ ::v-deep .dropdown-content {
+ background-color: #323846;
+ box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22);
+ }
+}
+</style>
diff --git a/src/site/components/loading/BulmaLoading.vue b/src/site/components/loading/BulmaLoading.vue
new file mode 100644
index 0000000..37cc5a5
--- /dev/null
+++ b/src/site/components/loading/BulmaLoading.vue
@@ -0,0 +1,33 @@
+<template>
+ <div class="loader-wrapper">
+ <div class="loader is-loading" />
+ </div>
+</template>
+
+<style lang="scss" scoped>
+ .loader-wrapper {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+ background: #fff;
+ opacity: 0;
+ z-index: -1;
+ transition: opacity .3s;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 6px;
+
+ .loader {
+ height: 80px;
+ width: 80px;
+ }
+
+ &.is-active {
+ opacity: 1;
+ z-index: 1;
+ }
+ }
+</style>
diff --git a/src/site/components/loading/CubeShadow.vue b/src/site/components/loading/CubeShadow.vue
new file mode 100644
index 0000000..7aeed65
--- /dev/null
+++ b/src/site/components/loading/CubeShadow.vue
@@ -0,0 +1,49 @@
+<template>
+ <div
+ :style="styles"
+ class="spinner spinner--cube-shadow" />
+</template>
+
+<script>
+export default {
+ props: {
+ size: {
+ 'type': String,
+ 'default': '60px'
+ },
+ background: {
+ 'type': String,
+ 'default': '#9C27B0'
+ },
+ duration: {
+ 'type': String,
+ 'default': '1.8s'
+ }
+ },
+ computed: {
+ styles() {
+ return {
+ width: this.size,
+ height: this.size,
+ backgroundColor: this.background,
+ animationDuration: this.duration
+ };
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+ .spinner{
+ animation: cube-shadow-spinner 1.8s cubic-bezier(0.75, 0, 0.5, 1) infinite;
+ }
+ @keyframes cube-shadow-spinner {
+ 50% {
+ border-radius: 50%;
+ transform: scale(0.5) rotate(360deg);
+ }
+ 100% {
+ transform: scale(1) rotate(720deg);
+ }
+ }
+</style>
diff --git a/src/site/components/loading/Origami.vue b/src/site/components/loading/Origami.vue
new file mode 100644
index 0000000..1c7a4c3
--- /dev/null
+++ b/src/site/components/loading/Origami.vue
@@ -0,0 +1,123 @@
+<template>
+ <div
+ :style="styles"
+ class="spinner spinner-origami">
+ <div
+ :style="innerStyles"
+ class="spinner-inner loading">
+ <span class="slice" />
+ <span class="slice" />
+ <span class="slice" />
+ <span class="slice" />
+ <span class="slice" />
+ <span class="slice" />
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ props: {
+ size: {
+ 'type': String,
+ 'default': '40px'
+ }
+ },
+ computed: {
+ innerStyles() {
+ const size = parseInt(this.size, 10);
+ return { transform: `scale(${(size / 60)})` };
+ },
+ styles() {
+ return {
+ width: this.size,
+ height: this.size
+ };
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+@import '../../styles/colors.scss';
+
+@for $i from 1 through 6 {
+ @keyframes origami-show-#{$i}{
+ from{
+ transform: rotateZ(60* $i + deg) rotateY(-90deg) rotateX(0deg);
+ border-left-color: #31855e;
+ }
+ }
+ @keyframes origami-hide-#{$i}{
+ to{
+ transform: rotateZ(60* $i + deg) rotateY(-90deg) rotateX(0deg);
+ border-left-color: #31855e;
+ }
+ }
+
+ @keyframes origami-cycle-#{$i} {
+
+ $startIndex: $i*5;
+ $reverseIndex: (80 - $i*5);
+
+ #{$startIndex * 1%} {
+ transform: rotateZ(60* $i + deg) rotateY(90deg) rotateX(0deg);
+ border-left-color: #31855e;
+ }
+ #{$startIndex + 5%},
+ #{$reverseIndex * 1%} {
+ transform: rotateZ(60* $i + deg) rotateY(0) rotateX(0deg);
+ border-left-color: #41b883;
+ }
+
+ #{$reverseIndex + 5%},
+ 100%{
+ transform: rotateZ(60* $i + deg) rotateY(90deg) rotateX(0deg);
+ border-left-color: #31855e;
+ }
+ }
+}
+
+.spinner{
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ * {
+ line-height: 0;
+ box-sizing: border-box;
+ }
+}
+.spinner-inner{
+ display: block;
+ width: 60px;
+ height: 68px;
+ .slice {
+ border-top: 18px solid transparent;
+ border-right: none;
+ border-bottom: 16px solid transparent;
+ border-left: 30px solid #f7484e;
+ position: absolute;
+ top: 0px;
+ left: 50%;
+ transform-origin: left bottom;
+ border-radius: 3px 3px 0 0;
+ }
+
+ @for $i from 1 through 6 {
+ .slice:nth-child(#{$i}) {
+ transform: rotateZ(60* $i + deg) rotateY(0deg) rotateX(0);
+ animation: .15s linear .9 - $i*.08s origami-hide-#{$i} both 1;
+ }
+ }
+
+ &.loading{
+ @for $i from 1 through 6 {
+ .slice:nth-child(#{$i}) {
+ transform: rotateZ(60* $i + deg) rotateY(90deg) rotateX(0);
+ animation: 2s origami-cycle-#{$i} linear infinite both;
+ }
+ }
+ }
+
+}
+</style>
diff --git a/src/site/components/loading/PingPong.vue b/src/site/components/loading/PingPong.vue
new file mode 100644
index 0000000..bab33d5
--- /dev/null
+++ b/src/site/components/loading/PingPong.vue
@@ -0,0 +1,100 @@
+<template>
+ <div
+ :style="styles"
+ class="spinner spinner--ping-pong">
+ <div
+ :style="innerStyles"
+ class="spinner-inner">
+ <div class="board">
+ <div class="left" />
+ <div class="right" />
+ <div class="ball" />
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ props: {
+ size: {
+ 'type': String,
+ 'default': '60px'
+ }
+ },
+ computed: {
+ innerStyles() {
+ const size = parseInt(this.size, 10);
+ return { transform: `scale(${size / 250})` };
+ },
+ styles() {
+ return {
+ width: this.size,
+ height: this.size
+ };
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+ .spinner{
+ overflow: hidden;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ * {
+ line-height: 0;
+ box-sizing: border-box;
+ }
+ }
+ .board {
+ width:250px;
+ position: relative;
+ }
+ .left,
+ .right {
+ height:50px;
+ width:15px;
+ background:#41b883;
+ display: inline-block;
+ position:absolute;
+ }
+ .left {
+ left:0;
+ animation: pingpong-position1 2s linear infinite;
+ }
+ .right {
+ right:0;
+ animation: pingpong-position2 2s linear infinite;
+ }
+ .ball{
+ width:15px;
+ height:15px;
+ border-radius:50%;
+ background:#f7484e;
+ position:absolute;
+ animation: pingpong-bounce 2s linear infinite;
+ }
+ @keyframes pingpong-position1 {
+ 0% {top:-60px;}
+ 25% {top:0;}
+ 50% {top:60px;}
+ 75% {top:-60px;}
+ 100% {top:-60px;}
+ }
+ @keyframes pingpong-position2 {
+ 0% {top:60px;}
+ 25% {top:0;}
+ 50% {top:-60px;}
+ 75% {top:-60px;}
+ 100% {top:60px;}
+ }
+ @keyframes pingpong-bounce {
+ 0% {top:-35px;left:10px;}
+ 25% {top:25px;left:225px;}
+ 50% {top:75px;left:10px;}
+ 75% {top:-35px;left:225px;}
+ 100% {top:-35px;left:10px;}
+ }
+</style>
diff --git a/src/site/components/loading/RotateSquare.vue b/src/site/components/loading/RotateSquare.vue
new file mode 100644
index 0000000..b7967ec
--- /dev/null
+++ b/src/site/components/loading/RotateSquare.vue
@@ -0,0 +1,88 @@
+<template>
+ <div
+ :style="styles"
+ class="spinner spinner--rotate-square-2" />
+</template>
+
+<script>
+export default {
+ props: {
+ size: {
+ 'type': String,
+ 'default': '40px'
+ }
+ },
+ computed: {
+ styles() {
+ return {
+ width: this.size,
+ height: this.size,
+ display: 'inline-block'
+ };
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+@import '../../styles/colors.scss';
+
+.spinner {
+ position: relative;
+ * {
+ line-height: 0;
+ box-sizing: border-box;
+ }
+ &:before {
+ content: '';
+ width: 100%;
+ height: 20%;
+ min-width: 5px;
+ background: #000;
+ opacity: 0.1;
+ position: absolute;
+ bottom: 0%;
+ left: 0;
+ border-radius: 50%;
+ animation: rotate-square-2-shadow .5s linear infinite;
+ }
+ &:after {
+ content: '';
+ width: 100%;
+ height: 100%;
+ background: $basePink;
+ animation: rotate-square-2-animate .5s linear infinite;
+ position: absolute;
+ bottom:40%;
+ left: 0;
+ border-radius: 3px;
+ }
+}
+
+@keyframes rotate-square-2-animate {
+ 17% {
+ border-bottom-right-radius: 3px;
+ }
+ 25% {
+ transform: translateY(20%) rotate(22.5deg);
+ }
+ 50% {
+ transform: translateY(40%) scale(1, .9) rotate(45deg);
+ border-bottom-right-radius: 50%;
+ }
+ 75% {
+ transform: translateY(20%) rotate(67.5deg);
+ }
+ 100% {
+ transform: translateY(0) rotate(90deg);
+ }
+}
+@keyframes rotate-square-2-shadow {
+ 0%, 100% {
+ transform: scale(1, 1);
+ }
+ 50% {
+ transform: scale(1.2, 1);
+ }
+}
+</style>
diff --git a/src/site/components/logo/Logo.vue b/src/site/components/logo/Logo.vue
new file mode 100644
index 0000000..5c2546b
--- /dev/null
+++ b/src/site/components/logo/Logo.vue
@@ -0,0 +1,27 @@
+<template>
+ <img src="~/assets/images/logo.png">
+</template>
+<style lang="scss" scoped>
+ img {
+ height: 376px;
+ animation-delay: 0.5s;
+ animation-duration: 1.5s;
+ animation-fill-mode: both;
+ animation-name: floatUp;
+ animation-timing-function: cubic-bezier(0, 0.71, 0.29, 1);
+ }
+
+ @keyframes floatUp {
+ 0% {
+ opacity: 0;
+ transform: scale(0.86);
+ }
+ 25% { opacity: 100; }
+ 67% {
+ transform: scale(1);
+ }
+ 100% {
+ transform: scale(1);
+ }
+ }
+</style>
diff --git a/src/site/components/navbar/Navbar.vue b/src/site/components/navbar/Navbar.vue
new file mode 100644
index 0000000..65db69f
--- /dev/null
+++ b/src/site/components/navbar/Navbar.vue
@@ -0,0 +1,137 @@
+<template>
+ <b-navbar
+ :class="{ isWhite }"
+ transparent>
+ <template slot="end">
+ <b-navbar-item tag="div">
+ <router-link
+ to="/"
+ class="navbar-item no-active"
+ exact>
+ Home
+ </router-link>
+ </b-navbar-item>
+ <template v-if="loggedIn">
+ <b-navbar-item tag="div">
+ <router-link
+ to="/dashboard"
+ class="navbar-item no-active"
+ exact>
+ Uploads
+ </router-link>
+ </b-navbar-item>
+ <b-navbar-item tag="div">
+ <router-link
+ to="/dashboard/albums"
+ class="navbar-item no-active"
+ exact>
+ Albums
+ </router-link>
+ </b-navbar-item>
+ <b-navbar-item tag="div">
+ <router-link
+ to="/dashboard/account"
+ class="navbar-item no-active"
+ exact>
+ Account
+ </router-link>
+ </b-navbar-item>
+ <b-navbar-item tag="div">
+ <router-link
+ to="/"
+ class="navbar-item no-active"
+ @click.native="logOut">
+ Logout
+ </router-link>
+ </b-navbar-item>
+ </template>
+ <template v-else>
+ <b-navbar-item tag="div">
+ <router-link
+ class="navbar-item"
+ to="/login">
+ Login
+ </router-link>
+ </b-navbar-item>
+ </template>
+ </template>
+ </b-navbar>
+</template>
+
+<script>
+import { mapState, mapGetters } from 'vuex';
+
+export default {
+ props: {
+ isWhite: {
+ 'type': Boolean,
+ 'default': false
+ }
+ },
+ data() {
+ return { hamburger: false };
+ },
+ computed: {
+ ...mapGetters({ loggedIn: 'auth/isLoggedIn' }),
+ ...mapState(['config'])
+ },
+ methods: {
+ async logOut() {
+ await this.$store.dispatch('auth/logout');
+ this.$router.replace('/login');
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+ nav.navbar {
+ background: transparent;
+ box-shadow: none;
+ padding-right: 2rem;
+ .navbar-brand {
+ a.burger {
+ color: $defaultTextColor;
+ }
+ }
+ .navbar-menu {
+ height: 5rem;
+
+ .navbar-end {
+ padding-right: 2rem;
+ }
+ a.navbar-item {
+ color: $defaultTextColor;
+ font-size: 16px;
+ font-weight: 700;
+ text-decoration-style: solid;
+ }
+ a.navbar-item:hover, a.navbar-item.is-active, a.navbar-link:hover, a.navbar-link.is-active {
+ text-decoration: underline;
+ background: transparent;
+ }
+
+ i {
+ font-size: 2em;
+ &.hidden {
+ width: 0px;
+ height: 1.5em;
+ pointer-events: none;
+ }
+ }
+ }
+
+ &.isWhite {
+ .navbar-brand {
+ a.navbar-item {
+ color: white;
+ }
+ }
+ }
+ }
+
+ .no-active {
+ text-decoration: none !important;
+ }
+</style>
diff --git a/src/site/components/search-input/SearchInput.vue b/src/site/components/search-input/SearchInput.vue
new file mode 100644
index 0000000..10728a0
--- /dev/null
+++ b/src/site/components/search-input/SearchInput.vue
@@ -0,0 +1,517 @@
+<template>
+ <div
+ class="autocomplete control"
+ :class="{'is-expanded': expanded}">
+ <b-input
+ ref="input"
+ v-model="newValue"
+ type="text"
+ :size="size"
+ :loading="loading"
+ :rounded="rounded"
+ :icon="icon"
+ :icon-right="newIconRight"
+ :icon-right-clickable="newIconRightClickable"
+ :icon-pack="iconPack"
+ :maxlength="maxlength"
+ :autocomplete="newAutocomplete"
+ :use-html5-validation="false"
+ v-bind="$attrs"
+ @input="onInput"
+ @focus="focused"
+ @blur="onBlur"
+ @keyup.native.esc.prevent="isActive = false"
+ @keydown.native.tab="tabPressed"
+ @keydown.native.enter.prevent="enterPressed"
+ @keydown.native.up.prevent="keyArrows('up')"
+ @keydown.native.down.prevent="keyArrows('down')"
+ @icon-right-click="rightIconClick"
+ @icon-click="(event) => $emit('icon-click', event)" />
+
+ <transition name="fade">
+ <div
+ v-show="isActive && (data.length > 0 || hasEmptySlot || hasHeaderSlot)"
+ ref="dropdown"
+ class="dropdown-menu"
+ :class="{ 'is-opened-top': isOpenedTop && !appendToBody }"
+ :style="style">
+ <div
+ v-show="isActive"
+ class="dropdown-content"
+ :style="contentStyle">
+ <div
+ v-if="hasHeaderSlot"
+ class="dropdown-item">
+ <slot name="header" />
+ </div>
+ <a
+ v-for="(option, index) in data"
+ :key="index"
+ class="dropdown-item"
+ :class="{ 'is-hovered': option === hovered }"
+ @click="setSelected(option, undefined, $event)">
+
+ <slot
+ v-if="hasDefaultSlot"
+ :option="option"
+ :index="index" />
+ <span v-else>
+ {{ getValue(option, true) }}
+ </span>
+ </a>
+ <div
+ v-if="data.length === 0 && hasEmptySlot"
+ class="dropdown-item is-disabled">
+ <slot name="empty" />
+ </div>
+ <div
+ v-if="hasFooterSlot"
+ class="dropdown-item">
+ <slot name="footer" />
+ </div>
+ </div>
+ </div>
+ </transition>
+ </div>
+</template>
+
+<script>
+/* eslint-disable no-underscore-dangle */
+/* eslint-disable vue/require-default-prop */
+/* eslint-disable vue/no-reserved-keys */
+import { getValueByPath, removeElement, createAbsoluteElement } from '../../../../node_modules/buefy/src/utils/helpers';
+import FormElementMixin from '../../../../node_modules/buefy/src/utils/FormElementMixin';
+
+export default {
+ name: 'SearchInput',
+ mixins: [FormElementMixin],
+ inheritAttrs: false,
+ props: {
+ value: [Number, String],
+ data: {
+ 'type': Array,
+ 'default': () => []
+ },
+ field: {
+ 'type': String,
+ 'default': 'value'
+ },
+ keepFirst: Boolean,
+ clearOnSelect: Boolean,
+ openOnFocus: Boolean,
+ customFormatter: Function,
+ checkInfiniteScroll: Boolean,
+ keepOpen: Boolean,
+ clearable: Boolean,
+ maxHeight: [String, Number],
+ dropdownPosition: {
+ 'type': String,
+ 'default': 'auto'
+ },
+ iconRight: String,
+ iconRightClickable: Boolean,
+ appendToBody: Boolean,
+ customSelector: Function
+ },
+ data() {
+ return {
+ selected: null,
+ hovered: null,
+ isActive: false,
+ newValue: this.value,
+ newAutocomplete: this.autocomplete || 'off',
+ isListInViewportVertically: true,
+ hasFocus: false,
+ style: {},
+ _isAutocomplete: true,
+ _elementRef: 'input',
+ _bodyEl: undefined // Used to append to body
+ };
+ },
+ computed: {
+ /**
+ * White-listed items to not close when clicked.
+ * Add input, dropdown and all children.
+ */
+ whiteList() {
+ const whiteList = [];
+ whiteList.push(this.$refs.input.$el.querySelector('input'));
+ whiteList.push(this.$refs.dropdown);
+ // Add all chidren from dropdown
+ if (this.$refs.dropdown !== undefined) {
+ const children = this.$refs.dropdown.querySelectorAll('*');
+ for (const child of children) {
+ whiteList.push(child);
+ }
+ }
+ if (this.$parent.$data._isTaginput) {
+ // Add taginput container
+ whiteList.push(this.$parent.$el);
+ // Add .tag and .delete
+ const tagInputChildren = this.$parent.$el.querySelectorAll('*');
+ for (const tagInputChild of tagInputChildren) {
+ whiteList.push(tagInputChild);
+ }
+ }
+ return whiteList;
+ },
+ /**
+ * Check if exists default slot
+ */
+ hasDefaultSlot() {
+ return Boolean(this.$scopedSlots.default);
+ },
+ /**
+ * Check if exists "empty" slot
+ */
+ hasEmptySlot() {
+ return Boolean(this.$slots.empty);
+ },
+ /**
+ * Check if exists "header" slot
+ */
+ hasHeaderSlot() {
+ return Boolean(this.$slots.header);
+ },
+ /**
+ * Check if exists "footer" slot
+ */
+ hasFooterSlot() {
+ return Boolean(this.$slots.footer);
+ },
+ /**
+ * Apply dropdownPosition property
+ */
+ isOpenedTop() {
+ return this.dropdownPosition === 'top' || (this.dropdownPosition === 'auto' && !this.isListInViewportVertically);
+ },
+ newIconRight() {
+ if (this.clearable && this.newValue) {
+ return 'close-circle';
+ }
+ return this.iconRight;
+ },
+ newIconRightClickable() {
+ if (this.clearable) {
+ return true;
+ }
+ return this.iconRightClickable;
+ },
+ contentStyle() {
+ return {
+ // eslint-disable-next-line no-nested-ternary
+ maxHeight: this.maxHeight === undefined
+ // eslint-disable-next-line no-restricted-globals
+ ? null
+ : (isNaN(this.maxHeight) ? this.maxHeight : `${this.maxHeight}px`)
+ };
+ }
+ },
+ watch: {
+ /**
+ * When dropdown is toggled, check the visibility to know when
+ * to open upwards.
+ */
+ isActive(active) {
+ if (this.dropdownPosition === 'auto') {
+ if (active) {
+ this.calcDropdownInViewportVertical();
+ } else {
+ // Timeout to wait for the animation to finish before recalculating
+ setTimeout(() => {
+ this.calcDropdownInViewportVertical();
+ }, 100);
+ }
+ }
+ if (active) this.$nextTick(() => this.setHovered(null));
+ },
+ /**
+ * When updating input's value
+ * 1. Emit changes
+ * 2. If value isn't the same as selected, set null
+ * 3. Close dropdown if value is clear or else open it
+ */
+ newValue(value) {
+ this.$emit('input', value);
+ // Check if selected is invalid
+ const currentValue = this.getValue(this.selected);
+ if (currentValue && currentValue !== value) {
+ this.setSelected(null, false);
+ }
+ // Close dropdown if input is clear or else open it
+ if (this.hasFocus && (!this.openOnFocus || value)) {
+ this.isActive = Boolean(value);
+ }
+ },
+ /**
+ * When v-model is changed:
+ * 1. Update internal value.
+ * 2. If it's invalid, validate again.
+ */
+ value(value) {
+ this.newValue = value;
+ },
+ /**
+ * Select first option if "keep-first
+ */
+ data(value) {
+ // Keep first option always pre-selected
+ if (this.keepFirst) {
+ this.selectFirstOption(value);
+ }
+ }
+ },
+ created() {
+ if (typeof window !== 'undefined') {
+ document.addEventListener('click', this.clickedOutside);
+ if (this.dropdownPosition === 'auto') window.addEventListener('resize', this.calcDropdownInViewportVertical);
+ }
+ },
+ mounted() {
+ if (this.checkInfiniteScroll && this.$refs.dropdown && this.$refs.dropdown.querySelector('.dropdown-content')) {
+ const list = this.$refs.dropdown.querySelector('.dropdown-content');
+ list.addEventListener('scroll', () => this.checkIfReachedTheEndOfScroll(list));
+ }
+ if (this.appendToBody) {
+ this.$data._bodyEl = createAbsoluteElement(this.$refs.dropdown);
+ this.updateAppendToBody();
+ }
+ },
+ beforeDestroy() {
+ if (typeof window !== 'undefined') {
+ document.removeEventListener('click', this.clickedOutside);
+ if (this.dropdownPosition === 'auto') window.removeEventListener('resize', this.calcDropdownInViewportVertical);
+ }
+ if (this.checkInfiniteScroll && this.$refs.dropdown && this.$refs.dropdown.querySelector('.dropdown-content')) {
+ const list = this.$refs.dropdown.querySelector('.dropdown-content');
+ list.removeEventListener('scroll', this.checkIfReachedTheEndOfScroll);
+ }
+ if (this.appendToBody) {
+ removeElement(this.$data._bodyEl);
+ }
+ },
+ methods: {
+ /**
+ * Set which option is currently hovered.
+ */
+ setHovered(option) {
+ if (option === undefined) return;
+ this.hovered = option;
+ },
+ /**
+ * Set which option is currently selected, update v-model,
+ * update input value and close dropdown.
+ */
+ setSelected(option, closeDropdown = true, event = undefined) {
+ if (option === undefined) return;
+ this.selected = option;
+ this.$emit('select', this.selected, event);
+ if (this.selected !== null) {
+ if (this.customSelector) {
+ this.newValue = this.clearOnSelect ? '' : this.customSelector(this.selected, this.newValue);
+ } else {
+ this.newValue = this.clearOnSelect ? '' : this.getValue(this.selected);
+ }
+ this.setHovered(null);
+ }
+ // eslint-disable-next-line no-unused-expressions
+ closeDropdown && this.$nextTick(() => { this.isActive = false; });
+ this.checkValidity();
+ },
+ /**
+ * Select first option
+ */
+ selectFirstOption(options) {
+ this.$nextTick(() => {
+ if (options.length) {
+ // If has visible data or open on focus, keep updating the hovered
+ if (this.openOnFocus || (this.newValue !== '' && this.hovered !== options[0])) {
+ this.setHovered(options[0]);
+ }
+ } else {
+ this.setHovered(null);
+ }
+ });
+ },
+ /**
+ * Enter key listener.
+ * Select the hovered option.
+ */
+ enterPressed(event) {
+ if (this.hovered === null) return;
+ this.setSelected(this.hovered, !this.keepOpen, event);
+ },
+ /**
+ * Tab key listener.
+ * Select hovered option if it exists, close dropdown, then allow
+ * native handling to move to next tabbable element.
+ */
+ tabPressed(event) {
+ if (this.hovered === null) {
+ this.isActive = false;
+ return;
+ }
+ this.setSelected(this.hovered, !this.keepOpen, event);
+ },
+ /**
+ * Close dropdown if clicked outside.
+ */
+ clickedOutside(event) {
+ if (this.whiteList.indexOf(event.target) < 0) this.isActive = false;
+ },
+ /**
+ * Return display text for the input.
+ * If object, get value from path, or else just the value.
+ */
+ getValue(option) {
+ if (option === null) return;
+ if (typeof this.customFormatter !== 'undefined') {
+ // eslint-disable-next-line consistent-return
+ return this.customFormatter(option);
+ }
+ // eslint-disable-next-line consistent-return
+ return typeof option === 'object'
+ ? getValueByPath(option, this.field)
+ : option;
+ },
+ /**
+ * Check if the scroll list inside the dropdown
+ * reached it's end.
+ */
+ checkIfReachedTheEndOfScroll(list) {
+ if (list.clientHeight !== list.scrollHeight &&
+ list.scrollTop + list.clientHeight >= list.scrollHeight) {
+ this.$emit('infinite-scroll');
+ }
+ },
+ /**
+ * Calculate if the dropdown is vertically visible when activated,
+ * otherwise it is openened upwards.
+ */
+ calcDropdownInViewportVertical() {
+ this.$nextTick(() => {
+ /**
+ * this.$refs.dropdown may be undefined
+ * when Autocomplete is conditional rendered
+ */
+ if (this.$refs.dropdown === undefined) return;
+ const rect = this.$refs.dropdown.getBoundingClientRect();
+ this.isListInViewportVertically = (
+ rect.top >= 0 &&
+ rect.bottom <= (window.innerHeight ||
+ document.documentElement.clientHeight)
+ );
+ if (this.appendToBody) {
+ this.updateAppendToBody();
+ }
+ });
+ },
+ /**
+ * Arrows keys listener.
+ * If dropdown is active, set hovered option, or else just open.
+ */
+ keyArrows(direction) {
+ const sum = direction === 'down' ? 1 : -1;
+ if (this.isActive) {
+ let index = this.data.indexOf(this.hovered) + sum;
+ index = index > this.data.length - 1 ? this.data.length : index;
+ index = index < 0 ? 0 : index;
+ this.setHovered(this.data[index]);
+ const list = this.$refs.dropdown.querySelector('.dropdown-content');
+ const element = list.querySelectorAll('a.dropdown-item:not(.is-disabled)')[index];
+ if (!element) return;
+ const visMin = list.scrollTop;
+ const visMax = list.scrollTop + list.clientHeight - element.clientHeight;
+ if (element.offsetTop < visMin) {
+ list.scrollTop = element.offsetTop;
+ } else if (element.offsetTop >= visMax) {
+ list.scrollTop = (
+ element.offsetTop -
+ list.clientHeight +
+ element.clientHeight
+ );
+ }
+ } else {
+ this.isActive = true;
+ }
+ },
+ /**
+ * Focus listener.
+ * If value is the same as selected, select all text.
+ */
+ focused(event) {
+ if (this.getValue(this.selected) === this.newValue) {
+ this.$el.querySelector('input').select();
+ }
+ if (this.openOnFocus) {
+ this.isActive = true;
+ if (this.keepFirst) {
+ this.selectFirstOption(this.data);
+ }
+ }
+ this.hasFocus = true;
+ this.$emit('focus', event);
+ },
+ /**
+ * Blur listener.
+ */
+ onBlur(event) {
+ this.hasFocus = false;
+ this.$emit('blur', event);
+ },
+ onInput() {
+ const currentValue = this.getValue(this.selected);
+ if (currentValue && currentValue === this.newValue) return;
+ this.$emit('typing', this.newValue);
+ this.checkValidity();
+ },
+ rightIconClick(event) {
+ if (this.clearable) {
+ this.newValue = '';
+ if (this.openOnFocus) {
+ this.$el.focus();
+ }
+ } else {
+ this.$emit('icon-right-click', event);
+ }
+ },
+ checkValidity() {
+ if (this.useHtml5Validation) {
+ this.$nextTick(() => {
+ this.checkHtml5Validity();
+ });
+ }
+ },
+ updateAppendToBody() {
+ const dropdownMenu = this.$refs.dropdown;
+ const trigger = this.$refs.input.$el;
+ if (dropdownMenu && trigger) {
+ // update wrapper dropdown
+ const root = this.$data._bodyEl;
+ root.classList.forEach(item => root.classList.remove(item));
+ root.classList.add('autocomplete');
+ root.classList.add('control');
+ if (this.expandend) {
+ root.classList.add('is-expandend');
+ }
+ const rect = trigger.getBoundingClientRect();
+ let top = rect.top + window.scrollY;
+ const left = rect.left + window.scrollX;
+ if (this.isOpenedTop) {
+ top -= dropdownMenu.clientHeight;
+ } else {
+ top += trigger.clientHeight;
+ }
+ this.style = {
+ position: 'absolute',
+ top: `${top}px`,
+ left: `${left}px`,
+ width: `${trigger.clientWidth}px`,
+ maxWidth: `${trigger.clientWidth}px`,
+ zIndex: '99'
+ };
+ }
+ }
+ }
+};
+</script>
diff --git a/src/site/components/search/Search.vue b/src/site/components/search/Search.vue
new file mode 100644
index 0000000..1f55691
--- /dev/null
+++ b/src/site/components/search/Search.vue
@@ -0,0 +1,145 @@
+<template>
+ <div class="level-right">
+ <div class="level-item">
+ <b-field>
+ <SearchInput
+ ref="autocomplete"
+ v-model="query"
+ :data="filteredHints"
+ :custom-selector="handleSelect"
+ field="name"
+ class="chibisafe-input search"
+ placeholder="Search"
+ type="search"
+ open-on-focus
+ @typing="handleTyping"
+ @keydown.native.enter="onSubmit">
+ <template slot-scope="props">
+ <b>{{ props.option.name }}:</b>
+ <small>
+ {{ props.option.valueFormat }}
+ </small>
+ </template>
+ </SearchInput>
+ <p class="control">
+ <b-button type="is-chibisafe" @click="onSubmit">
+ Search
+ </b-button>
+ </p>
+ </b-field>
+ </div>
+ </div>
+</template>
+
+<script>
+import SearchInput from '~/components/search-input/SearchInput.vue';
+
+export default {
+ components: {
+ SearchInput
+ },
+ props: {
+ hiddenHints: {
+ 'type': Array,
+ 'default': () => []
+ }
+ },
+ data() {
+ return {
+ query: '',
+ hints: [
+ {
+ name: 'tag',
+ valueFormat: 'name',
+ hint: ''
+ },
+ {
+ name: 'album',
+ valueFormat: 'name',
+ hint: ''
+ },
+ {
+ name: 'before',
+ valueFormat: 'specific date',
+ hint: ''
+ },
+ {
+ name: 'during',
+ valueFormat: 'specific date',
+ hint: ''
+ },
+ {
+ name: 'after',
+ valueFormat: 'specific date',
+ hint: ''
+ },
+ {
+ name: 'file',
+ valueFormat: 'generated name',
+ hint: ''
+ }
+ ],
+ filteredHints: []
+ };
+ },
+ created() {
+ this.hints = this.hints.filter(({ name }) => this.hiddenHints.indexOf(name) === -1);
+ this.filteredHints = this.hints; // fixes the issue where on pageload, suggestions wont load
+ },
+ methods: {
+ handleSelect(selected, currentValue) {
+ this.$refs.autocomplete.focus();
+ if (!currentValue) { return `${selected.name}:`; }
+ if (/[^:][\s|;|,]+$/gi.test(currentValue)) return `${currentValue}${selected.name}:`;
+ return currentValue.replace(/\w+$/gi, `${selected.name}:`);
+ },
+ handleTyping(qry) {
+ qry = qry || '';
+ // get the last word or group of words
+ let lastWord = (qry.match(/("[^"]*")|[^\s]+/g) || ['']).pop().toLowerCase();
+ // if there's an open/unbalanced quote, don't autosuggest
+ if (/^[^"]*("[^"]*"[^"]*)*(")[^"]*$/.test(qry)) {
+ this.filteredHints = [];
+ return;
+ }
+ // don't autosuggest if we have an open query but no text yet
+ if (/:\s+$/gi.test(qry)) {
+ this.filteredHints = [];
+ return;
+ }
+ // if the above query didn't match (all quotes are balanced
+ // and the previous tag has value
+ // check if we're about to start a new tag
+ if (/\s+$/gi.test(qry)) {
+ this.filteredHints = this.hints;
+ return;
+ }
+
+ // ignore starting `-` from lastword, because - is used to
+ // exclude something, so -alb should autosuggest album
+ lastWord = lastWord.replace(/^-/, '');
+
+ // if we got here, then we handled all special cases
+ // now take last word, and check if we can autosuggest a tag
+ this.filteredHints = this.hints.filter(hint => hint.name
+ .toString()
+ .toLowerCase()
+ .indexOf(lastWord) === 0);
+ },
+ onSubmit(event) {
+ if (event.key === 'Enter') {
+ if (/:$/gi.test(this.query)) { return; }
+ }
+ this.$emit('search', this.query, event);
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+ .search {
+ ::v-deep .dropdown-content {
+ background-color: #323846;
+ }
+ }
+</style>
diff --git a/src/site/components/sidebar/Sidebar.vue b/src/site/components/sidebar/Sidebar.vue
new file mode 100644
index 0000000..8d96712
--- /dev/null
+++ b/src/site/components/sidebar/Sidebar.vue
@@ -0,0 +1,82 @@
+<template>
+ <b-menu class="dashboard-menu">
+ <b-menu-list label="Menu">
+ <b-menu-item
+ class="item"
+ icon="information-outline"
+ label="Dashboard"
+ tag="nuxt-link"
+ to="/dashboard"
+ exact />
+ <b-menu-item
+ class="item"
+ icon="image-multiple-outline"
+ label="Albums"
+ tag="nuxt-link"
+ to="/dashboard/albums"
+ exact />
+ <b-menu-item
+ class="item"
+ icon="tag-outline"
+ label="Tags"
+ tag="nuxt-link"
+ to="/dashboard/tags"
+ exact />
+ <b-menu-item icon="menu" expanded>
+ <template slot="label" slot-scope="props">
+ Administration
+ <b-icon class="is-pulled-right" :icon="props.expanded ? 'menu-down' : 'menu-up'" />
+ </template>
+ <b-menu-item icon="account" label="Users" tag="nuxt-link" to="/dashboard/admin/users" exact />
+ <b-menu-item icon="cog-outline" label="Settings" tag="nuxt-link" to="/dashboard/admin/settings" exact />
+ </b-menu-item>
+ <b-menu-item
+ class="item"
+ icon="account-cog-outline"
+ label="My account"
+ tag="nuxt-link"
+ to="/dashboard/account"
+ exact />
+ </b-menu-list>
+ <b-menu-list label="Actions">
+ <b-menu-item icon="exit-to-app" label="Logout" tag="nuxt-link" to="/logout" exact />
+ </b-menu-list>
+ </b-menu>
+</template>
+<script>
+import { mapState } from 'vuex';
+
+export default {
+ computed: mapState({
+ user: state => state.auth.user
+ }),
+ methods: {
+ isRouteActive(id) {
+ if (this.$route.path.includes(id)) {
+ return true;
+ }
+ return false;
+ }
+ }
+};
+
+</script>
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+ .dashboard-menu {
+ ::v-deep a:hover {
+ cursor: pointer;
+ text-decoration: none;
+ }
+
+ ::v-deep .icon {
+ margin-right: 0.5rem;
+ }
+
+ ::v-deep .icon.is-pulled-right {
+ margin-right: 0;
+ }
+
+ hr { margin-top: 0.6em; }
+ }
+</style>
diff --git a/src/site/components/uploader/Uploader.vue b/src/site/components/uploader/Uploader.vue
new file mode 100644
index 0000000..f180546
--- /dev/null
+++ b/src/site/components/uploader/Uploader.vue
@@ -0,0 +1,258 @@
+<template>
+ <div
+ :class="{ 'has-files': alreadyAddedFiles }"
+ class="uploader-wrapper">
+ <b-select
+ v-if="loggedIn"
+ v-model="selectedAlbum"
+ placeholder="Upload to album"
+ size="is-medium"
+ expanded>
+ <option
+ v-for="album in albums"
+ :key="album.id"
+ :value="album.id">
+ {{ album.name }}
+ </option>
+ </b-select>
+ <dropzone
+ v-if="showDropzone"
+ id="dropzone"
+ ref="el"
+ :options="dropzoneOptions"
+ :include-styling="false"
+ @vdropzone-success="dropzoneSuccess"
+ @vdropzone-error="dropzoneError"
+ @vdropzone-files-added="dropzoneFilesAdded" />
+ <label class="add-more">
+ Add or drop more files
+ </label>
+
+ <div
+ id="template"
+ ref="template">
+ <div class="dz-preview dz-file-preview">
+ <div class="dz-details">
+ <div class="dz-filename">
+ <span data-dz-name />
+ </div>
+ <div class="dz-size">
+ <span data-dz-size />
+ </div>
+ </div>
+ <div class="result">
+ <div class="openLink">
+ <a
+ class="link"
+ target="_blank">
+ Link
+ </a>
+ </div>
+ </div>
+ <div class="error">
+ <div>
+ <span>
+ <span
+ class="error-message"
+ data-dz-errormessage />
+ <i class="icon-web-warning" />
+ </span>
+ </div>
+ </div>
+ <div class="dz-progress">
+ <span
+ class="dz-upload"
+ data-dz-uploadprogress />
+ </div>
+ <!--
+ <div class="dz-error-message"><span data-dz-errormessage/></div>
+ <div class="dz-success-mark"><i class="fa fa-check"/></div>
+ <div class="dz-error-mark"><i class="fa fa-close"/></div>
+ -->
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import { mapState, mapGetters } from 'vuex';
+
+import Dropzone from 'nuxt-dropzone';
+import '~/assets/styles/dropzone.scss';
+
+export default {
+ components: { Dropzone },
+ data() {
+ return {
+ alreadyAddedFiles: false,
+ files: [],
+ dropzoneOptions: {},
+ showDropzone: false,
+ selectedAlbum: null
+ };
+ },
+ computed: {
+ ...mapState({
+ config: state => state.config,
+ albums: state => state.albums.tinyDetails
+ }),
+ ...mapGetters({ loggedIn: 'auth/isLoggedIn', token: 'auth/getToken' })
+ },
+ watch: {
+ loggedIn() {
+ this.getAlbums();
+ },
+ selectedAlbum() {
+ this.updateDropzoneConfig();
+ }
+ },
+ mounted() {
+ this.dropzoneOptions = {
+ url: `${this.config.baseURL}/upload`,
+ timeout: 600000, // 10 minutes
+ autoProcessQueue: true,
+ addRemoveLinks: false,
+ parallelUploads: 5,
+ uploadMultiple: false,
+ maxFiles: 1000,
+ createImageThumbnails: false,
+ paramName: 'files[]',
+ forceChunking: false,
+ chunking: true,
+ retryChunks: true,
+ retryChunksLimit: 3,
+ parallelChunkUploads: true,
+ chunkSize: this.config.chunkSize * 1000000,
+ chunksUploaded: this.dropzoneChunksUploaded,
+ maxFilesize: this.config.maxFileSize,
+ previewTemplate: this.$refs.template.innerHTML,
+ dictDefaultMessage: 'Drag & Drop your files or click to browse',
+ headers: { Accept: 'application/vnd.chibisafe.json' }
+ };
+ this.showDropzone = true;
+ if (this.loggedIn) this.getAlbums();
+ },
+ methods: {
+ /*
+ Get all available albums so the user can upload directly to one (or several soonâ„¢) of them.
+ */
+ async getAlbums() {
+ try {
+ await this.$store.dispatch('albums/getTinyDetails');
+ } catch (e) {
+ this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+ this.updateDropzoneConfig();
+ },
+
+ /*
+ This method needs to be called after the token or selectedAlbum changes
+ since dropzone doesn't seem to update the config values unless you force it.
+ Tch.
+ */
+ updateDropzoneConfig() {
+ this.$refs.el.setOption('headers', {
+ Accept: 'application/vnd.chibisafe.json',
+ Authorization: this.token ? `Bearer ${this.token}` : '',
+ albumId: this.selectedAlbum ? this.selectedAlbum : null
+ });
+ },
+
+ /*
+ Dropzone stuff
+ */
+ dropzoneFilesAdded() {
+ this.alreadyAddedFiles = true;
+ },
+ dropzoneSuccess(file, response) {
+ this.processResult(file, response);
+ },
+ dropzoneError(file, message, xhr) {
+ this.$store.dispatch('alert', {
+ text: 'There was an error uploading this file. Check the console.',
+ error: true
+ });
+ // eslint-disable-next-line no-console
+ console.error(file, message, xhr);
+ },
+ async dropzoneChunksUploaded(file, done) {
+ const { data } = await this.$axios.post(`${this.config.baseURL}/upload/chunks`, {
+ files: [{
+ uuid: file.upload.uuid,
+ original: file.name,
+ size: file.size,
+ type: file.type,
+ count: file.upload.totalChunkCount
+ }]
+ }, {
+ headers: {
+ albumId: this.selectedAlbum ? this.selectedAlbum : null
+ }
+ });
+
+ this.processResult(file, data);
+ return done();
+ },
+
+ /*
+ If upload/s was/were successfull we modify the template so that the buttons for
+ copying the returned url or opening it in a new window appear.
+ */
+ processResult(file, response) {
+ if (!response.url) return;
+ file.previewTemplate.querySelector('.link').setAttribute('href', response.url);
+ /*
+ file.previewTemplate.querySelector('.copyLink').addEventListener('click', () => {
+ this.$store.dispatch('alert', {
+ text: 'Link copied!'
+ });
+ this.$clipboard(response.url);
+ });
+ */
+ }
+ }
+};
+</script>
+<style lang="scss" scoped>
+ #template { display: none; }
+ .uploader-wrapper {
+ display: block;
+ width: 400px;
+ margin: 0 auto;
+ max-width: 100%;
+ position: relative;
+ }
+</style>
+<style lang="scss">
+ @import '~/assets/styles/_colors.scss';
+
+ div.uploader-wrapper {
+ &.has-files {
+ #dropzone {
+ padding-bottom: 50px;
+ }
+ label.add-more {
+ position: absolute;
+ bottom: 23px;
+ width: 100%;
+ text-align: center;
+ color: #797979;
+ display: block;
+ pointer-events: none;
+ }
+ }
+ div.control {
+ margin-bottom: 5px;
+ span.select {
+ select {
+ border: 1px solid #00000061;
+ background: rgba(0, 0, 0, 0.15);
+ border-radius: .3em;
+ color: $uploaderDropdownColor;
+ padding: 0 0 0 1rem;
+ }
+ }
+ }
+ label.add-more { display: none; }
+ }
+</style>
diff --git a/src/site/constants/alertTypes.js b/src/site/constants/alertTypes.js
new file mode 100644
index 0000000..458a739
--- /dev/null
+++ b/src/site/constants/alertTypes.js
@@ -0,0 +1,10 @@
+export default {
+ PRIMARY: 'is-primary',
+ INFO: 'is-info',
+ SUCCESS: 'is-success',
+ WARNING: 'is-warning',
+ ERROR: 'is-danger',
+ DARK: 'is-dark',
+ LIGHT: 'is-light',
+ WHITE: 'is-white'
+};
diff --git a/src/site/layouts/default.vue b/src/site/layouts/default.vue
new file mode 100644
index 0000000..1a53292
--- /dev/null
+++ b/src/site/layouts/default.vue
@@ -0,0 +1,106 @@
+<template>
+ <div
+ v-bar
+ class="scroll-area">
+ <div class="default-body">
+ <div class="background" />
+ <Navbar :is-white="true" />
+ <nuxt-child
+ id="app"
+ class="nuxt-app is-height-max-content" />
+ <Footer />
+ </div>
+ </div>
+</template>
+<script>
+import { mapState } from 'vuex';
+import Navbar from '~/components/navbar/Navbar.vue';
+import Footer from '~/components/footer/Footer.vue';
+
+export default {
+ components: {
+ Navbar,
+ Footer
+ },
+ computed: mapState(['config', 'alert']),
+ created() {
+ this.$store.watch(state => state.alert.message, () => {
+ const { message, type, snackbar } = this.alert;
+
+ if (!message) return;
+
+ if (snackbar) {
+ this.$buefy.snackbar.open({
+ duration: 3500,
+ position: 'is-bottom',
+ message,
+ type
+ });
+ } else {
+ this.$buefy.toast.open({
+ duration: 3500,
+ position: 'is-bottom',
+ message,
+ type
+ });
+ }
+
+ this.$store.dispatch('alert/clear', null);
+ });
+ },
+ mounted() {
+ // eslint-disable-next-line no-console
+ console.log(
+ `%c chibisafe %c v${this.config.version} %c`,
+ 'background:#35495e; padding: 1px; border-radius: 3px 0 0 3px; color: #fff',
+ 'background:#ff015b; padding: 1px; border-radius: 0 3px 3px 0; color: #fff',
+ 'background:transparent'
+ );
+ }
+};
+</script>
+
+<style lang="scss">
+@import '~/assets/styles/style.scss';
+html {
+ overflow: hidden !important;
+}
+.is-fullheight {
+ min-height: 100vh !important;
+ height: max-content;
+}
+.nuxt-app > .section {
+ min-height: auto !important;
+ height: auto !important;
+}
+</style>
+<style lang="scss" scoped>
+.default-body {
+ align-items: baseline !important;
+ overflow: auto;
+ height: 100vh;
+ .background {
+ position: fixed;
+ top: 0;
+ left: 0;
+ background: no-repeat scroll 50% 50%;
+ background-size: cover;
+ z-index: -1;
+ height: 100vh;
+ width: 100%;
+ pointer-events: none;
+ background-image: url('~assets/images/background.jpg');
+ // Uncomment the following to test different backgrounds
+ /*
+ top: -10%;
+ left: -10%;
+ height: 120vh;
+ width: 120%;
+ filter: blur(15px);
+ */
+ }
+}
+.scroll-area {
+ height: 100vh;
+}
+</style>
diff --git a/src/site/layouts/error.vue b/src/site/layouts/error.vue
new file mode 100644
index 0000000..28f3036
--- /dev/null
+++ b/src/site/layouts/error.vue
@@ -0,0 +1,28 @@
+<style lang="scss" scoped>
+@import "~/assets/styles/_colors.scss";
+ h2 {
+ font-weight: 100;
+ color: $textColor;
+ font-size: 4em;
+ text-align: center;
+ }
+</style>
+
+<template>
+ <section class="hero is-fullheight">
+ <Navbar :is-white="true" />
+ <div class="hero-body">
+ <div class="container">
+ <h2>404エラ</h2>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import Navbar from '~/components/navbar/Navbar.vue';
+
+export default {
+ components: { Navbar }
+};
+</script>
diff --git a/src/site/middleware/admin.js b/src/site/middleware/admin.js
new file mode 100644
index 0000000..9a99153
--- /dev/null
+++ b/src/site/middleware/admin.js
@@ -0,0 +1,6 @@
+export default function({ store, redirect }) {
+ // If the user is not authenticated
+ if (!store.state.auth.user) return redirect('/login');
+ if (!store.state.auth.user.isAdmin) return redirect('/dashboard');
+ return true;
+}
diff --git a/src/site/middleware/auth.js b/src/site/middleware/auth.js
new file mode 100644
index 0000000..020326f
--- /dev/null
+++ b/src/site/middleware/auth.js
@@ -0,0 +1,7 @@
+export default function({ store, redirect }) {
+ // If the user is not authenticated
+ if (!store.state.auth.loggedIn) {
+ return redirect('/login');
+ }
+ return true;
+}
diff --git a/src/site/pages/a/_identifier.vue b/src/site/pages/a/_identifier.vue
new file mode 100644
index 0000000..7ffed35
--- /dev/null
+++ b/src/site/pages/a/_identifier.vue
@@ -0,0 +1,153 @@
+<template>
+ <section class="section is-fullheight">
+ <template v-if="files && files.length">
+ <div class="align-top">
+ <div class="container">
+ <h1 class="title">
+ {{ name }}
+ </h1>
+ <h2 class="subtitle">
+ Serving {{ files ? files.length : 0 }} files
+ </h2>
+ <a
+ v-if="downloadLink"
+ :href="downloadLink">Download Album</a>
+ <hr>
+ </div>
+ </div>
+ <div class="container">
+ <template v-if="!isNsfw || (isNsfw && nsfwConsent)">
+ <Grid
+ v-if="files && files.length"
+ :files="files"
+ :is-public="true"
+ :width="200"
+ :enable-search="false"
+ :enable-toolbar="false" />
+ </template>
+ <template v-else>
+ <div class="nsfw">
+ <i class="mdi mdi-alert mdi-48px" />
+ <h1>NSFW Content</h1>
+ <p>
+ This album contains images or videos that are not safe for work or are inappropriate to view in some situations.<br>
+ Do you wish to proceed?
+ </p>
+ <button
+ class="button is-danger"
+ @click="nsfwConsent = true">
+ Show me the content
+ </button>
+ </div>
+ </template>
+ </div>
+ </template>
+ <template v-else>
+ <div class="container">
+ <h1 class="title">
+ :(
+ </h1>
+ <h2 class="subtitle">
+ This album seems to be empty
+ </h2>
+ </div>
+ </template>
+ </section>
+</template>
+
+<script>
+import axios from 'axios';
+import Grid from '~/components/grid/Grid.vue';
+
+export default {
+ components: { Grid },
+ data() {
+ return {
+ nsfwConsent: false
+ };
+ },
+ computed: {
+ config() {
+ return this.$store.state.config;
+ }
+ },
+ async asyncData({ app, params, error }) {
+ try {
+ const { data } = await axios.get(`${app.store.state.config.baseURL}/album/${params.identifier}`);
+ const downloadLink = data.downloadEnabled ? `${app.store.state.config.baseURL}/album/${params.identifier}/zip` : null;
+ return {
+ name: data.name,
+ downloadEnabled: data.downloadEnabled,
+ files: data.files,
+ downloadLink,
+ isNsfw: data.isNsfw
+ };
+ } catch (err) {
+ console.log('Error when retrieving album', err);
+ error({ statusCode: 404, message: 'Album not found' });
+ }
+ },
+ metaInfo() {
+ if (this.files) {
+ return {
+ title: `${this.name ? this.name : ''}`,
+ meta: [
+ { vmid: 'theme-color', name: 'theme-color', content: '#30a9ed' },
+ { vmid: 'twitter:card', name: 'twitter:card', content: 'summary' },
+ { vmid: 'twitter:title', name: 'twitter:title', content: `Album: ${this.name} | Files: ${this.files.length}` },
+ { vmid: 'twitter:description', name: 'twitter:description', content: 'A modern and self-hosted file upload service that can handle anything you throw at it. Fast uploads, file manager and sharing capabilities all crafted with a beautiful user experience in mind.' },
+ { vmid: 'twitter:image', name: 'twitter:image', content: `${this.files.length > 0 ? this.files[0].thumbSquare : '/public/images/share.jpg'}` },
+ { vmid: 'twitter:image:src', name: 'twitter:image:src', value: `${this.files.length > 0 ? this.files[0].thumbSquare : '/public/images/share.jpg'}` },
+
+ { vmid: 'og:url', property: 'og:url', content: `${this.config.URL}/a/${this.$route.params.identifier}` },
+ { vmid: 'og:title', property: 'og:title', content: `Album: ${this.name} | Files: ${this.files.length}` },
+ { vmid: 'og:description', property: 'og:description', content: 'A modern and self-hosted file upload service that can handle anything you throw at it. Fast uploads, file manager and sharing capabilities all crafted with a beautiful user experience in mind.' },
+ { vmid: 'og:image', property: 'og:image', content: `${this.files.length > 0 ? this.files[0].thumbSquare : '/public/images/share.jpg'}` },
+ { vmid: 'og:image:secure_url', property: 'og:image:secure_url', content: `${this.files.length > 0 ? this.files[0].thumbSquare : '/public/images/share.jpg'}` }
+ ]
+ };
+ }
+ return {
+ title: `${this.name ? this.name : ''}`,
+ meta: [
+ { vmid: 'theme-color', name: 'theme-color', content: '#30a9ed' },
+ { vmid: 'twitter:card', name: 'twitter:card', content: 'summary' },
+ { vmid: 'twitter:title', name: 'twitter:title', content: 'chibisafe' },
+ { vmid: 'twitter:description', name: 'twitter:description', content: 'A modern and self-hosted file upload service that can handle anything you throw at it. Fast uploads, file manager and sharing capabilities all crafted with a beautiful user experience in mind.' },
+ { vmid: 'og:url', property: 'og:url', content: `${this.config.URL}/a/${this.$route.params.identifier}` },
+ { vmid: 'og:title', property: 'og:title', content: 'chibisafe' },
+ { vmid: 'og:description', property: 'og:description', content: 'A modern and self-hosted file upload service that can handle anything you throw at it. Fast uploads, file manager and sharing capabilities all crafted with a beautiful user experience in mind.' }
+ ]
+ };
+ }
+};
+</script>
+<style lang="scss" scoped>
+ section.hero div.hero-body.align-top {
+ align-items: baseline;
+ flex-grow: 0;
+ padding-bottom: 0;
+ }
+
+ div.loading-container {
+ justify-content: center;
+ display: flex;
+ }
+ .nsfw {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ min-height: 50vh;
+
+ h1 {
+ font-size: 2rem;
+ margin-bottom: 2rem;
+ }
+ p {
+ font-size: 1.5rem;
+ margin-bottom: 2rem;
+ text-align: center;
+ }
+ }
+</style>
diff --git a/src/site/pages/dashboard/account.vue b/src/site/pages/dashboard/account.vue
new file mode 100644
index 0000000..3a9d37c
--- /dev/null
+++ b/src/site/pages/dashboard/account.vue
@@ -0,0 +1,174 @@
+<template>
+ <section class="section is-fullheight dashboard">
+ <div class="container">
+ <div class="columns">
+ <div class="column is-narrow">
+ <Sidebar />
+ </div>
+ <div class="column">
+ <h2 class="subtitle">
+ Account settings
+ </h2>
+ <hr>
+
+ <b-field
+ label="Username"
+ message="Nothing to do here"
+ horizontal>
+ <b-input
+ class="chibisafe-input"
+ :value="user.username"
+ expanded
+ disabled />
+ </b-field>
+
+ <b-field
+ label="Current password"
+ message="If you want to change your password input the current one here"
+ horizontal>
+ <b-input
+ v-model="password"
+ class="chibisafe-input"
+ type="password"
+ expanded />
+ </b-field>
+
+ <b-field
+ label="New password"
+ message="Your new password"
+ horizontal>
+ <b-input
+ v-model="newPassword"
+ class="chibisafe-input"
+ type="password"
+ expanded />
+ </b-field>
+
+ <b-field
+ label="New password again"
+ message="Your new password once again"
+ horizontal>
+ <b-input
+ v-model="reNewPassword"
+ class="chibisafe-input"
+ type="password"
+ expanded />
+ </b-field>
+
+ <div class="mb2 mt2 text-center">
+ <b-button
+ type="is-chibisafe"
+ @click="changePassword">
+ Change password
+ </b-button>
+ </div>
+
+ <b-field
+ label="API key"
+ message="This API key lets you use the service from other apps"
+ horizontal>
+ <b-field expanded>
+ <b-input
+ class="chibisafe-input"
+ :value="apiKey"
+ expanded
+ disabled />
+ <p class="control">
+ <b-button
+ type="is-chibisafe"
+ @click="copyKey">
+ Copy
+ </b-button>
+ </p>
+ </b-field>
+ </b-field>
+
+ <div class="mb2 mt2 text-center">
+ <b-button
+ type="is-chibisafe"
+ @click="promptNewAPIKey">
+ Request new API key
+ </b-button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import { mapState, mapActions, mapGetters } from 'vuex';
+import Sidebar from '~/components/sidebar/Sidebar.vue';
+
+export default {
+ components: {
+ Sidebar
+ },
+ middleware: ['auth', ({ store }) => {
+ store.dispatch('auth/fetchCurrentUser');
+ }],
+ data() {
+ return {
+ password: '',
+ newPassword: '',
+ reNewPassword: ''
+ };
+ },
+ computed: {
+ ...mapGetters({ apiKey: 'auth/getApiKey' }),
+ ...mapState({
+ user: state => state.auth.user
+ })
+ },
+ metaInfo() {
+ return { title: 'Account' };
+ },
+ methods: {
+ ...mapActions({
+ getUserSetttings: 'auth/fetchCurrentUser'
+ }),
+ async changePassword() {
+ const { password, newPassword, reNewPassword } = this;
+
+ if (!password || !newPassword || !reNewPassword) {
+ this.$store.dispatch('alert/set', {
+ text: 'One or more fields are missing',
+ error: true
+ });
+ return;
+ }
+ if (newPassword !== reNewPassword) {
+ this.$store.dispatch('alert/set', {
+ text: 'Passwords don\'t match',
+ error: true
+ });
+ return;
+ }
+
+ const response = await this.$store.dispatch('auth/changePassword', {
+ password,
+ newPassword
+ });
+
+ if (response) {
+ this.$buefy.toast.open(response.message);
+ }
+ },
+ promptNewAPIKey() {
+ this.$buefy.dialog.confirm({
+ type: 'is-danger',
+ message: 'Are you sure you want to regenerate your API key? Previously generated API keys will stop working. Make sure to write the new key down as this is the only time it will be displayed to you.',
+ onConfirm: () => this.requestNewAPIKey()
+ });
+ },
+ copyKey() {
+ this.$clipboard(this.apiKey);
+ this.$notifier.success('API key copied to clipboard');
+ },
+ async requestNewAPIKey() {
+ const response = await this.$store.dispatch('auth/requestAPIKey');
+ this.$buefy.toast.open(response.message);
+ }
+ }
+};
+</script>
diff --git a/src/site/pages/dashboard/admin/file/_id.vue b/src/site/pages/dashboard/admin/file/_id.vue
new file mode 100644
index 0000000..135d066
--- /dev/null
+++ b/src/site/pages/dashboard/admin/file/_id.vue
@@ -0,0 +1,176 @@
+<style lang="scss" scoped>
+ .underline { text-decoration: underline; }
+</style>
+<template>
+ <section class="section is-fullheight dashboard">
+ <div class="container">
+ <div class="columns">
+ <div class="column is-narrow">
+ <Sidebar />
+ </div>
+ <div class="column">
+ <h2 class="subtitle">
+ File details
+ </h2>
+ <hr>
+
+ <div class="columns">
+ <div class="column is-6">
+ <b-field
+ label="ID"
+ horizontal>
+ <span>{{ admin.file.id }}</span>
+ </b-field>
+
+ <b-field
+ label="Name"
+ horizontal>
+ <span>{{ admin.file.name }}</span>
+ </b-field>
+
+ <b-field
+ label="Original Name"
+ horizontal>
+ <span>{{ admin.file.original }}</span>
+ </b-field>
+
+ <b-field
+ label="IP"
+ horizontal>
+ <span class="underline">{{ admin.file.ip }}</span>
+ </b-field>
+
+ <b-field
+ label="Link"
+ horizontal>
+ <a
+ :href="admin.file.url"
+ target="_blank">{{ admin.file.url }}</a>
+ </b-field>
+
+ <b-field
+ label="Size"
+ horizontal>
+ <span>{{ formatBytes(admin.file.size) }}</span>
+ </b-field>
+
+ <b-field
+ label="Hash"
+ horizontal>
+ <span>{{ admin.file.hash }}</span>
+ </b-field>
+
+ <b-field
+ label="Uploaded"
+ horizontal>
+ <span><timeago :since="admin.file.createdAt" /></span>
+ </b-field>
+ </div>
+ <div class="column is-6">
+ <b-field
+ label="User Id"
+ horizontal>
+ <span>{{ admin.user.id }}</span>
+ </b-field>
+
+ <b-field
+ label="Username"
+ horizontal>
+ <span>{{ admin.user.username }}</span>
+ </b-field>
+
+ <b-field
+ label="Enabled"
+ horizontal>
+ <span>{{ admin.user.enabled }}</span>
+ </b-field>
+
+ <b-field
+ label="Registered"
+ horizontal>
+ <span><timeago :since="admin.user.createdAt" /></span>
+ </b-field>
+
+ <b-field
+ label="Files"
+ horizontal>
+ <span>
+ <nuxt-link :to="`/dashboard/admin/user/${admin.user.id}`">{{ admin.user.fileCount }}</nuxt-link>
+ </span>
+ </b-field>
+ </div>
+ </div>
+
+ <div class="mb2 mt2 text-center">
+ <b-button
+ v-if="admin.user.id !== auth.user.id"
+ type="is-danger"
+ @click="promptBanIP">
+ Ban IP
+ </b-button>
+ <b-button
+ v-if="admin.user.id !== auth.user.id"
+ type="is-danger"
+ @click="promptDisableUser">
+ Disable user
+ </b-button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import { mapState } from 'vuex';
+import Sidebar from '~/components/sidebar/Sidebar.vue';
+
+export default {
+ components: {
+ Sidebar
+ },
+ middleware: ['auth', 'admin', ({ route, store }) => {
+ try {
+ store.dispatch('admin/fetchFile', route.params.id);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+ }],
+ computed: mapState(['admin', 'auth']),
+ methods: {
+ promptDisableUser() {
+ this.$buefy.dialog.confirm({
+ type: 'is-danger',
+ message: 'Are you sure you want to disable the account of the user that uploaded this file?',
+ onConfirm: () => this.disableUser()
+ });
+ },
+ disableUser() {
+ this.$handler.executeAction('admin/disableUser', this.user.id);
+ },
+ promptBanIP() {
+ this.$buefy.dialog.confirm({
+ type: 'is-danger',
+ message: 'Are you sure you want to ban the IP this file was uploaded from?',
+ onConfirm: () => this.banIP()
+ });
+ },
+ banIP() {
+ this.$handler.executeAction('admin/banIP', this.file.ip);
+ },
+ formatBytes(bytes, decimals = 2) {
+ if (bytes === 0) return '0 Bytes';
+
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ // eslint-disable-next-line no-mixed-operators
+ return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
+ }
+ }
+};
+</script>
diff --git a/src/site/pages/dashboard/admin/settings.vue b/src/site/pages/dashboard/admin/settings.vue
new file mode 100644
index 0000000..71df2a6
--- /dev/null
+++ b/src/site/pages/dashboard/admin/settings.vue
@@ -0,0 +1,162 @@
+<template>
+ <section class="section is-fullheight dashboard">
+ <div class="container">
+ <div class="columns">
+ <div class="column is-narrow">
+ <Sidebar />
+ </div>
+ <div class="column">
+ <h2 class="subtitle">
+ Service settings
+ </h2>
+ <hr>
+
+ <b-field
+ label="Service name"
+ message="Please enter the name which this service is gonna be identified as"
+ horizontal>
+ <b-input
+ v-model="settings.serviceName"
+ class="chibisafe-input"
+ expanded />
+ </b-field>
+
+ <b-field
+ label="Upload folder"
+ message="Where to store the files relative to the working directory"
+ horizontal>
+ <b-input
+ v-model="settings.uploadFolder"
+ class="chibisafe-input"
+ expanded />
+ </b-field>
+
+ <b-field
+ label="Links per album"
+ message="Maximum links allowed per album"
+ horizontal>
+ <b-input
+ v-model="settings.linksPerAlbum"
+ class="chibisafe-input"
+ type="number"
+ expanded />
+ </b-field>
+
+ <b-field
+ label="Max upload size"
+ message="Maximum allowed file size in MB"
+ horizontal>
+ <b-input
+ v-model="settings.maxUploadSize"
+ class="chibisafe-input"
+ expanded />
+ </b-field>
+
+ <b-field
+ label="Filename length"
+ message="How many characters long should the generated filenames be"
+ horizontal>
+ <b-input
+ v-model="settings.filenameLength"
+ class="chibisafe-input"
+ expanded />
+ </b-field>
+
+ <b-field
+ label="Album link length"
+ message="How many characters a link for an album should have"
+ horizontal>
+ <b-input
+ v-model="settings.albumLinkLength"
+ class="chibisafe-input"
+ expanded />
+ </b-field>
+
+ <b-field
+ label="Generate thumbnails"
+ message="Generate thumbnails when uploading a file if possible"
+ horizontal>
+ <b-switch
+ v-model="settings.generateThumbnails"
+ :true-value="true"
+ :false-value="false" />
+ </b-field>
+
+ <b-field
+ label="Generate zips"
+ message="Allow generating zips to download entire albums"
+ horizontal>
+ <b-switch
+ v-model="settings.generateZips"
+ :true-value="true"
+ :false-value="false" />
+ </b-field>
+
+ <b-field
+ label="Public mode"
+ message="Enable anonymous uploades"
+ horizontal>
+ <b-switch
+ v-model="settings.publicMode"
+ :true-value="true"
+ :false-value="false" />
+ </b-field>
+
+ <b-field
+ label="Enable creating account"
+ message="Enable creating new accounts in the platform"
+ horizontal>
+ <b-switch
+ v-model="settings.enableAccounts"
+ :true-value="true"
+ :false-value="false" />
+ </b-field>
+
+ <div class="mb2 mt2 text-center">
+ <button
+ class="button is-primary"
+ @click="promptRestartService">
+ Save and restart service
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import { mapState } from 'vuex';
+import Sidebar from '~/components/sidebar/Sidebar.vue';
+
+export default {
+ components: {
+ Sidebar
+ },
+ middleware: ['auth', 'admin', ({ store }) => {
+ try {
+ store.dispatch('admin/fetchSettings');
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+ }],
+ metaInfo() {
+ return { title: 'Settings' };
+ },
+ computed: mapState({
+ settings: state => state.admin.settings
+ }),
+ methods: {
+ promptRestartService() {
+ this.$buefy.dialog.confirm({
+ message: 'Keep in mind that restarting only works if you have PM2 or something similar set up. Continue?',
+ onConfirm: () => this.restartService()
+ });
+ },
+ restartService() {
+ this.$handler.executeAction('admin/restartService');
+ }
+ }
+};
+</script>
diff --git a/src/site/pages/dashboard/admin/user/_id.vue b/src/site/pages/dashboard/admin/user/_id.vue
new file mode 100644
index 0000000..0ed3e86
--- /dev/null
+++ b/src/site/pages/dashboard/admin/user/_id.vue
@@ -0,0 +1,120 @@
+<style lang="scss" scoped>
+ .underline { text-decoration: underline; }
+</style>
+<template>
+ <section class="section is-fullheight dashboard">
+ <div class="container">
+ <div class="columns">
+ <div class="column is-narrow">
+ <Sidebar />
+ </div>
+ <div class="column">
+ <h2 class="subtitle">
+ User details
+ </h2>
+ <hr>
+
+ <b-field
+ label="User Id"
+ horizontal>
+ <span>{{ user.id }}</span>
+ </b-field>
+
+ <b-field
+ label="Username"
+ horizontal>
+ <span>{{ user.username }}</span>
+ </b-field>
+
+ <b-field
+ label="Enabled"
+ horizontal>
+ <span>{{ user.enabled }}</span>
+ </b-field>
+
+ <b-field
+ label="Registered"
+ horizontal>
+ <span><timeago :since="user.createdAt" /></span>
+ </b-field>
+
+ <b-field
+ label="Files"
+ horizontal>
+ <span>{{ user.files.length }}</span>
+ </b-field>
+
+ <div class="mb2 mt2 text-center">
+ <b-button
+ v-if="user.enabled"
+ type="is-danger"
+ @click="promptDisableUser">
+ Disable user
+ </b-button>
+ <b-button
+ v-if="!user.enabled"
+ type="is-success"
+ @click="promptEnableUser">
+ Enable user
+ </b-button>
+ </div>
+
+ <Grid
+ v-if="user.files.length"
+ :files="user.files" />
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import { mapState } from 'vuex';
+import Sidebar from '~/components/sidebar/Sidebar.vue';
+import Grid from '~/components/grid/Grid.vue';
+
+export default {
+ components: {
+ Sidebar,
+ Grid
+ },
+ middleware: ['auth', 'admin', ({ route, store }) => {
+ try {
+ store.dispatch('admin/fetchUser', route.params.id);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+ }],
+ data() {
+ return {
+ options: {}
+ };
+ },
+ computed: mapState({
+ user: state => state.admin.user
+ }),
+ methods: {
+ promptDisableUser() {
+ this.$buefy.dialog.confirm({
+ type: 'is-danger',
+ message: 'Are you sure you want to disable the account of this user?',
+ onConfirm: () => this.disableUser()
+ });
+ },
+ promptEnableUser() {
+ this.$buefy.dialog.confirm({
+ type: 'is-danger',
+ message: 'Are you sure you want to enable the account of this user?',
+ onConfirm: () => this.enableUser()
+ });
+ },
+ disableUser() {
+ this.$handler.executeAction('admin/disableUser', this.user.id);
+ },
+ enableUser() {
+ this.$handler.executeAction('admin/enableUser', this.user.id);
+ }
+ }
+};
+</script>
diff --git a/src/site/pages/dashboard/admin/users.vue b/src/site/pages/dashboard/admin/users.vue
new file mode 100644
index 0000000..d86bffd
--- /dev/null
+++ b/src/site/pages/dashboard/admin/users.vue
@@ -0,0 +1,247 @@
+<template>
+ <section class="section is-fullheight dashboard">
+ <div class="container">
+ <div class="columns">
+ <div class="column is-narrow">
+ <Sidebar />
+ </div>
+ <div class="column">
+ <h2 class="subtitle">
+ Manage your users
+ </h2>
+ <hr>
+
+ <div class="view-container">
+ <b-table
+ :data="users"
+ :mobile-cards="true">
+ <b-table-column
+ v-slot="props"
+ field="id"
+ label="Id"
+ centered>
+ {{ props.row.id }}
+ </b-table-column>
+
+ <b-table-column
+ v-slot="props"
+ field="username"
+ label="Username"
+ centered>
+ <nuxt-link :to="`/dashboard/admin/user/${props.row.id}`">
+ {{ props.row.username }}
+ </nuxt-link>
+ </b-table-column>
+
+ <b-table-column
+ v-slot="props"
+ field="enabled"
+ label="Enabled"
+ centered>
+ <b-switch
+ :value="props.row.enabled"
+ @input="changeEnabledStatus(props.row)" />
+ </b-table-column>
+
+ <b-table-column
+ v-slot="props"
+ field="isAdmin"
+ label="Admin"
+ centered>
+ <b-switch
+ :value="props.row.isAdmin"
+ @input="changeIsAdmin(props.row)" />
+ </b-table-column>
+
+ <b-table-column
+ v-slot="props"
+ field="purge"
+ centered>
+ <b-button
+ type="is-danger"
+ @click="promptPurgeFiles(props.row)">
+ Purge files
+ </b-button>
+ </b-table-column>
+
+ <template slot="empty">
+ <div class="has-text-centered">
+ <i class="icon-misc-mood-sad" />
+ </div>
+ <div class="has-text-centered">
+ Nothing here
+ </div>
+ </template>
+ <template slot="footer">
+ <div class="has-text-right">
+ {{ users.length }} users
+ </div>
+ </template>
+ </b-table>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import { mapState } from 'vuex';
+import Sidebar from '~/components/sidebar/Sidebar.vue';
+
+export default {
+ components: {
+ Sidebar
+ },
+ middleware: ['auth', 'admin', ({ route, store }) => {
+ try {
+ store.dispatch('admin/fetchUsers', route.params.id);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+ }],
+ computed: mapState({
+ users: state => state.admin.users,
+ config: state => state.config
+ }),
+ metaInfo() {
+ return { title: 'Uploads' };
+ },
+ methods: {
+ async changeEnabledStatus(row) {
+ if (row.enabled) {
+ this.$handler.executeAction('admin/disableUser', row.id);
+ } else {
+ this.$handler.executeAction('admin/enableUser', row.id);
+ }
+ },
+ async changeIsAdmin(row) {
+ if (row.isAdmin) {
+ this.$handler.executeAction('admin/demoteUser', row.id);
+ } else {
+ this.$handler.executeAction('admin/promoteUser', row.id);
+ }
+ },
+ promptPurgeFiles(row) {
+ this.$buefy.dialog.confirm({
+ message: 'Are you sure you want to delete this user\'s files?',
+ onConfirm: () => this.purgeFiles(row)
+ });
+ },
+ async purgeFiles(row) {
+ this.$handler.executeAction('admin/purgeUserFiles', row.id);
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+ div.view-container {
+ padding: 2rem;
+ }
+ div.album {
+ display: flex;
+ flex-wrap: wrap;
+ margin-bottom: 10px;
+
+ div.arrow-container {
+ width: 2em;
+ height: 64px;
+ position: relative;
+ cursor: pointer;
+
+ i {
+ border: 2px solid $defaultTextColor;
+ border-right: 0;
+ border-top: 0;
+ display: block;
+ height: 1em;
+ position: absolute;
+ transform: rotate(-135deg);
+ transform-origin: center;
+ width: 1em;
+ z-index: 4;
+ top: 22px;
+ transition: transform 0.1s linear;
+
+ &.active {
+ transform: rotate(-45deg);
+ }
+ }
+ }
+ div.thumb {
+ width: 64px;
+ height: 64px;
+ box-shadow: $boxShadowLight;
+ }
+
+ div.info {
+ margin-left: 15px;
+ h4 {
+ font-size: 1.5rem;
+ a {
+ color: $defaultTextColor;
+ font-weight: 400;
+ &:hover { text-decoration: underline; }
+ }
+ }
+ span { display: block; }
+ span:nth-child(3) {
+ font-size: 0.9rem;
+ }
+ }
+
+ div.latest {
+ flex-grow: 1;
+ justify-content: flex-end;
+ display: flex;
+ margin-left: 15px;
+
+ span.no-files {
+ font-size: 1.5em;
+ color: #b1b1b1;
+ padding-top: 17px;
+ }
+
+ div.more {
+ width: 64px;
+ height: 64px;
+ background: white;
+ display: flex;
+ align-items: center;
+ padding: 10px;
+ text-align: center;
+ a {
+ line-height: 1rem;
+ color: $defaultTextColor;
+ &:hover { text-decoration: underline; }
+ }
+ }
+ }
+
+ div.details {
+ flex: 0 1 100%;
+ padding-left: 2em;
+ padding-top: 1em;
+ min-height: 50px;
+
+ .b-table {
+ padding: 2em 0em;
+
+ .table-wrapper {
+ box-shadow: $boxShadowLight;
+ }
+ }
+ }
+ }
+
+ div.column > h2.subtitle { padding-top: 1px; }
+
+ .b-table {
+ .table-wrapper {
+ box-shadow: $boxShadowLight;
+ }
+ }
+</style>
diff --git a/src/site/pages/dashboard/albums/_id.vue b/src/site/pages/dashboard/albums/_id.vue
new file mode 100644
index 0000000..cf27a15
--- /dev/null
+++ b/src/site/pages/dashboard/albums/_id.vue
@@ -0,0 +1,128 @@
+<style lang="scss" scoped>
+ .albumsModal .columns .column { padding: .25rem; }
+</style>
+
+<template>
+ <section class="section is-fullheight dashboard">
+ <div class="container">
+ <div class="columns">
+ <div class="column is-narrow">
+ <Sidebar />
+ </div>
+ <div class="column">
+ <nav class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <h1 class="title is-3">
+ {{ images.albumName }}
+ </h1>
+ </div>
+ <div class="level-item">
+ <h2 class="subtitle is-5">
+ ({{ totalFiles }} files)
+ </h2>
+ </div>
+ </div>
+ <div class="level-right">
+ <div class="level-item">
+ <Search :hidden-hints="['album']" />
+ </div>
+ </div>
+ </nav>
+
+ <hr>
+
+ <Grid
+ v-if="
+ totalFiles"
+ :files="images.files"
+ :total="totalFiles">
+ <template v-slot:pagination>
+ <b-pagination
+ v-if="shouldPaginate"
+ :total="totalFiles"
+ :per-page="limit"
+ :current.sync="current"
+ range-before="2"
+ range-after="2"
+ class="pagination-slot"
+ icon-prev="icon-interface-arrow-left"
+ icon-next="icon-interface-arrow-right"
+ icon-pack="icon"
+ aria-next-label="Next page"
+ aria-previous-label="Previous page"
+ aria-page-label="Page"
+ aria-current-label="Current page" />
+ </template>
+ </Grid>
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import { mapState, mapGetters, mapActions } from 'vuex';
+
+import Sidebar from '~/components/sidebar/Sidebar.vue';
+import Grid from '~/components/grid/Grid.vue';
+import Search from '~/components/search/Search.vue';
+
+export default {
+ components: {
+ Sidebar,
+ Grid,
+ Search
+ },
+ middleware: ['auth', ({ route, store }) => {
+ store.commit('images/resetState');
+ store.dispatch('images/fetchByAlbumId', { id: route.params.id });
+ }],
+ data() {
+ return {
+ current: 1
+ };
+ },
+ computed: {
+ ...mapGetters({
+ totalFiles: 'images/getTotalFiles',
+ shouldPaginate: 'images/shouldPaginate',
+ limit: 'images/getLimit'
+ }),
+ ...mapState(['images']),
+ id() {
+ return this.$route.params.id;
+ }
+ },
+ metaInfo() {
+ return { title: 'Album' };
+ },
+ watch: {
+ current: 'fetchPaginate'
+ },
+ methods: {
+ ...mapActions({
+ fetch: 'images/fetchByAlbumId'
+ }),
+ fetchPaginate() {
+ this.fetch({ id: this.id, page: this.current });
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+ div.grid {
+ margin-bottom: 1rem;
+ }
+
+ .pagination-slot {
+ padding: 1rem 0;
+ }
+</style>
+
+<style lang="scss">
+ .pagination-slot > .pagination-previous, .pagination-slot > .pagination-next {
+ display: none !important;
+ }
+</style>
diff --git a/src/site/pages/dashboard/albums/index.vue b/src/site/pages/dashboard/albums/index.vue
new file mode 100644
index 0000000..d2b424b
--- /dev/null
+++ b/src/site/pages/dashboard/albums/index.vue
@@ -0,0 +1,110 @@
+<template>
+ <section class="hero is-fullheight dashboard">
+ <div class="hero-body">
+ <div class="container">
+ <div class="columns">
+ <div class="column is-narrow">
+ <Sidebar />
+ </div>
+ <div class="column">
+ <h2 class="subtitle">
+ Manage your albums
+ </h2>
+ <hr>
+
+ <div class="search-container">
+ <b-field>
+ <b-input
+ v-model="newAlbumName"
+ class="chibisafe-input"
+ placeholder="Album name..."
+ type="text"
+ @keyup.enter.native="createAlbum" />
+ <p class="control">
+ <button
+ outlined
+ class="button is-black"
+ :disabled="isCreatingAlbum"
+ @click="createAlbum">
+ Create album
+ </button>
+ </p>
+ </b-field>
+ </div>
+
+ <div class="view-container">
+ <AlbumEntry
+ v-for="album in albums.list"
+ :key="album.id"
+ :album="album" />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import { mapState, mapActions } from 'vuex';
+import Sidebar from '~/components/sidebar/Sidebar.vue';
+import AlbumEntry from '~/components/album/AlbumEntry.vue';
+
+export default {
+ components: {
+ Sidebar,
+ AlbumEntry
+ },
+ middleware: ['auth', ({ store }) => {
+ try {
+ store.dispatch('albums/fetch');
+ } catch (e) {
+ this.alert({ text: e.message, error: true });
+ }
+ }],
+ data() {
+ return {
+ newAlbumName: null,
+ isCreatingAlbum: false
+ };
+ },
+ computed: mapState(['config', 'albums']),
+ metaInfo() {
+ return { title: 'Uploads' };
+ },
+ methods: {
+ ...mapActions({
+ alert: 'alert/set'
+ }),
+ async createAlbum() {
+ if (!this.newAlbumName || this.newAlbumName === '') return;
+
+ this.isCreatingAlbum = true;
+ try {
+ const response = await this.$store.dispatch('albums/createAlbum', this.newAlbumName);
+
+ this.alert({ text: response.message, error: false });
+ } catch (e) {
+ this.alert({ text: e.message, error: true });
+ } finally {
+ this.isCreatingAlbum = false;
+ this.newAlbumName = null;
+ }
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+ div.view-container {
+ padding: 2rem;
+ }
+
+ div.search-container {
+ padding: 1rem 2rem;
+ background-color: $base-2;
+ }
+
+ div.column > h2.subtitle { padding-top: 1px; }
+</style>
diff --git a/src/site/pages/dashboard/index.vue b/src/site/pages/dashboard/index.vue
new file mode 100644
index 0000000..0b60cdc
--- /dev/null
+++ b/src/site/pages/dashboard/index.vue
@@ -0,0 +1,144 @@
+<template>
+ <section class="section is-fullheight dashboard">
+ <div class="container">
+ <div class="columns ">
+ <div class="column is-narrow">
+ <Sidebar />
+ </div>
+ <div class="column">
+ <nav class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <h2 class="subtitle">
+ Your uploaded files
+ </h2>
+ </div>
+ </div>
+ <div class="level-right">
+ <div class="level-item">
+ <Search @search="onSearch" />
+ </div>
+ </div>
+ </nav>
+ <hr>
+
+ <!-- <b-loading :active="images.isLoading" /> -->
+
+ <Grid
+ v-if="totalFiles && !isLoading"
+ :files="images.files"
+ :enable-search="false"
+ class="grid">
+ <template v-slot:pagination>
+ <b-pagination
+ v-if="shouldPaginate"
+ :total="totalFiles"
+ :per-page="limit"
+ :current.sync="current"
+ range-before="2"
+ range-after="2"
+ class="pagination-slot"
+ icon-prev="icon-interface-arrow-left"
+ icon-next="icon-interface-arrow-right"
+ icon-pack="icon"
+ aria-next-label="Next page"
+ aria-previous-label="Previous page"
+ aria-page-label="Page"
+ aria-current-label="Current page" />
+ </template>
+ </Grid>
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import { mapState, mapGetters, mapActions } from 'vuex';
+
+import Sidebar from '~/components/sidebar/Sidebar.vue';
+import Grid from '~/components/grid/Grid.vue';
+import Search from '~/components/search/Search.vue';
+
+export default {
+ components: {
+ Sidebar,
+ Grid,
+ Search
+ },
+ middleware: ['auth', ({ store }) => {
+ store.commit('images/resetState');
+ store.dispatch('images/fetch');
+ }],
+ data() {
+ return {
+ current: 1,
+ isLoading: false,
+ search: ''
+ };
+ },
+ computed: {
+ ...mapGetters({
+ totalFiles: 'images/getTotalFiles',
+ shouldPaginate: 'images/shouldPaginate',
+ limit: 'images/getLimit'
+ }),
+ ...mapState(['images'])
+ },
+ metaInfo() {
+ return { title: 'Uploads' };
+ },
+ watch: {
+ current: 'fetchPaginate'
+ },
+ created() {
+ this.filteredHints = this.hints; // fixes the issue where on pageload, suggestions wont load
+ },
+ methods: {
+ ...mapActions({
+ fetch: 'images/fetch'
+ }),
+ async fetchPaginate() {
+ this.isLoading = true;
+ await this.fetch(this.current);
+ this.isLoading = false;
+ },
+ sanitizeQuery(qry) {
+ // remove spaces between a search type selector `album:`
+ // and the value (ex `tag: 123` -> `tag:123`)
+ return (qry || '').replace(/(\w+):\s+/gi, '$1:');
+ },
+ async onSearch(query) {
+ this.search = query;
+
+ const sanitizedQ = this.sanitizeQuery(query);
+ // eslint-disable-next-line no-negated-condition
+ if (!sanitizedQ.length) {
+ this.current = 1;
+ await this.fetch(this.current);
+ } else {
+ this.$handler.executeAction('images/search', {
+ q: this.sanitizeQuery(query),
+ page: this.current
+ });
+ }
+ }
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+ div.grid {
+ margin-bottom: 1rem;
+ }
+
+ .pagination-slot {
+ padding: 1rem 0;
+ }
+</style>
+
+<style lang="scss">
+ .pagination-slot > .pagination-previous, .pagination-slot > .pagination-next {
+ display: none !important;
+ }
+</style>
diff --git a/src/site/pages/dashboard/tags/index.vue b/src/site/pages/dashboard/tags/index.vue
new file mode 100644
index 0000000..8790f47
--- /dev/null
+++ b/src/site/pages/dashboard/tags/index.vue
@@ -0,0 +1,270 @@
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+ div.view-container {
+ padding: 2rem;
+ }
+ div.album {
+ display: flex;
+ flex-wrap: wrap;
+ margin-bottom: 10px;
+
+ div.arrow-container {
+ width: 2em;
+ height: 64px;
+ position: relative;
+ cursor: pointer;
+
+ i {
+ border: 2px solid $defaultTextColor;
+ border-right: 0;
+ border-top: 0;
+ display: block;
+ height: 1em;
+ position: absolute;
+ transform: rotate(-135deg);
+ transform-origin: center;
+ width: 1em;
+ z-index: 4;
+ top: 22px;
+ transition: transform 0.1s linear;
+
+ &.active {
+ transform: rotate(-45deg);
+ }
+ }
+ }
+ div.thumb {
+ width: 64px;
+ height: 64px;
+ box-shadow: $boxShadowLight;
+ }
+
+ div.info {
+ margin-left: 15px;
+ h4 {
+ font-size: 1.5rem;
+ a {
+ color: $defaultTextColor;
+ font-weight: 400;
+ &:hover { text-decoration: underline; }
+ }
+ }
+ span { display: block; }
+ span:nth-child(3) {
+ font-size: 0.9rem;
+ }
+ }
+
+ div.latest {
+ flex-grow: 1;
+ justify-content: flex-end;
+ display: flex;
+ margin-left: 15px;
+
+ span.no-files {
+ font-size: 1.5em;
+ color: #b1b1b1;
+ padding-top: 17px;
+ }
+
+ div.more {
+ width: 64px;
+ height: 64px;
+ background: white;
+ display: flex;
+ align-items: center;
+ padding: 10px;
+ text-align: center;
+ a {
+ line-height: 1rem;
+ color: $defaultTextColor;
+ &:hover { text-decoration: underline; }
+ }
+ }
+ }
+
+ div.details {
+ flex: 0 1 100%;
+ padding-left: 2em;
+ padding-top: 1em;
+ min-height: 50px;
+
+ .b-table {
+ padding: 2em 0em;
+
+ .table-wrapper {
+ box-shadow: $boxShadowLight;
+ }
+ }
+ }
+ }
+
+ div.column > h2.subtitle { padding-top: 1px; }
+
+ div.no-background {
+ background: none;
+ }
+</style>
+<style lang="scss">
+ @import '~/assets/styles/_colors.scss';
+
+ .b-table {
+ .table-wrapper {
+ box-shadow: $boxShadowLight;
+ }
+ }
+</style>
+
+<template>
+ <section class="section is-fullheight dashboard">
+ <div class="container">
+ <div class="columns">
+ <div class="column is-narrow">
+ <Sidebar />
+ </div>
+ <div class="column">
+ <h2 class="subtitle">
+ Manage your tags
+ </h2>
+ <hr>
+
+ <div class="search-container">
+ <b-field>
+ <b-input
+ v-model="newTagName"
+ class="chibisafe-input"
+ placeholder="Tag name..."
+ type="text"
+ @keyup.enter.native="createTag" />
+ <p class="control">
+ <b-button
+ type="is-chibisafe"
+ @click="createTag">
+ Create tags
+ </b-button>
+ </p>
+ </b-field>
+ </div>
+
+ <div class="view-container">
+ <div
+ v-for="tag in tags"
+ :key="tag.id"
+ class="album">
+ <div
+ class="arrow-container"
+ @click="promptDeleteTag">
+ <i class="icon-arrow" />
+ </div>
+ <!--
+ <div class="thumb">
+ <figure class="image is-64x64 thumb">
+ <img src="~/assets/images/blank.png">
+ </figure>
+ </div>
+ -->
+ <div class="info">
+ <h4>
+ <router-link :to="`/dashboard/tags/${tag.id}`">
+ {{ tag.name }}
+ </router-link>
+ </h4>
+ <span>{{ tag.count || 0 }} files</span>
+ </div>
+ <!--
+ <div class="latest is-hidden-mobile">
+ <template v-if="album.fileCount > 0">
+ <div v-for="file of album.files"
+ :key="file.id"
+ class="thumb">
+ <figure class="image is-64x64">
+ <a :href="file.url"
+ target="_blank">
+ <img :src="file.thumbSquare">
+ </a>
+ </figure>
+ </div>
+ <div v-if="album.fileCount > 5"
+ class="thumb more no-background">
+ <router-link :to="`/dashboard/albums/${album.uuid}`">{{ album.fileCount - 5 }}+ more</router-link>
+ </div>
+ </template>
+ <template v-else>
+ <span class="no-files">Nothing to show here</span>
+ </template>
+ </div>
+ -->
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import Sidebar from '~/components/sidebar/Sidebar.vue';
+
+export default {
+ components: {
+ Sidebar
+ },
+ middleware: 'auth',
+ data() {
+ return {
+ tags: [],
+ newTagName: null
+ };
+ },
+ computed: {
+ config() {
+ return this.$store.state.config;
+ }
+ },
+ metaInfo() {
+ return { title: 'Tags' };
+ },
+ mounted() {
+ this.getTags();
+ },
+ methods: {
+ promptDeleteTag(id) {
+ this.$buefy.dialog.confirm({
+ type: 'is-danger',
+ message: 'Are you sure you want to delete this tag?',
+ onConfirm: () => this.promptPurgeTag(id)
+ });
+ },
+ promptPurgeTag(id) {
+ this.$buefy.dialog.confirm({
+ type: 'is-danger',
+ message: 'Would you like to delete every file associated with this tag?',
+ cancelText: 'No',
+ confirmText: 'Yes',
+ onConfirm: () => this.deleteTag(id, true),
+ onCancel: () => this.deleteTag(id, false)
+ });
+ },
+ async deleteTag(id, purge) {
+ const response = await this.$axios.$delete(`tags/${id}/${purge ? 'purge' : ''}`);
+ this.getTags();
+ return this.$buefy.toast.open(response.message);
+ },
+ async createTag() {
+ if (!this.newTagName || this.newTagName === '') return;
+ const response = await this.$axios.$post('tag/new',
+ { name: this.newTagName });
+ this.newTagName = null;
+ this.$buefy.toast.open(response.message);
+ this.getTags();
+ },
+ async getTags() {
+ const response = await this.$axios.$get('tags');
+ for (const tag of response.tags) {
+ tag.isDetailsOpen = false;
+ }
+ this.tags = response.tags;
+ }
+ }
+};
+</script>
diff --git a/src/site/pages/faq.vue b/src/site/pages/faq.vue
new file mode 100644
index 0000000..049cad7
--- /dev/null
+++ b/src/site/pages/faq.vue
@@ -0,0 +1,66 @@
+<template>
+ <!-- eslint-disable max-len -->
+ <div class="container has-text-left">
+ <h2 class="subtitle">
+ What is chibisafe?
+ </h2>
+ <article class="message">
+ <div class="message-body">
+ chibisafe is an easy to use, open source and completely free file upload service. We accept your files, photos, documents, anything, and give you back a shareable link for you to send to others.
+ </div>
+ </article>
+
+ <h2 class="subtitle">
+ Can I run my own chibisafe?
+ </h2>
+ <article class="message">
+ <div class="message-body">
+ Definitely. Head to <a target="_blank" href="https://github.com/WeebDev/chibisafe">our GitHub repo</a> and follow the instructions to clone, build and deploy it by yourself. It's super easy too!
+ </div>
+ </article>
+
+ <h2 class="subtitle">
+ How can I keep track of my uploads?
+ </h2>
+ <article class="message">
+ <div class="message-body">
+ Simply create a user on the site and every upload will be associated with your account, granting you access to your uploaded files through our dashboard.
+ </div>
+ </article>
+
+ <h2 class="subtitle">
+ What are albums?
+ </h2>
+ <article class="message">
+ <div class="message-body">
+ Albums are a simple way of sorting uploads together. Right now you can create albums through the dashboard and use them only with <a target="_blank" href="https://chrome.google.com/webstore/detail/lolisafe-uploader/enkkmplljfjppcdaancckgilmgoiofnj">our chrome extension</a> which will enable you to <strong>right click -&gt; send to chibisafe</strong> or to a desired album if you have any.
+ </div>
+ </article>
+
+ <h2 class="subtitle">
+ Why should I use this?
+ </h2>
+ <article class="message">
+ <div class="message-body">
+ There are too many file upload services out there, and a lot of them rely on the foundations of pomf which is ancient. In a desperate and unsuccessful attempt of finding a good file uploader that's easily extendable, chibisafe was born. We give you control over your files, we give you a way to sort your uploads into albums for ease of access and we give you an api to use with ShareX or any other thing that let's you make POST requests.
+ </div>
+ </article>
+ </div>
+</template>
+
+<script>
+export default {
+ name: 'Faq',
+ data() {
+ return {};
+ },
+ metaInfo() {
+ return { title: 'Faq' };
+ }
+};
+</script>
+
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+ article.message { background-color: #ffffff; }
+</style>
diff --git a/src/site/pages/index.vue b/src/site/pages/index.vue
new file mode 100644
index 0000000..8bdd23d
--- /dev/null
+++ b/src/site/pages/index.vue
@@ -0,0 +1,102 @@
+<template>
+ <div>
+ <div class="logoContainer">
+ <Logo />
+ </div>
+ <div class="leftSpacer">
+ <div class="mainBlock">
+ <div>
+ <h4>Blazing fast file uploader. For real.</h4>
+ <p>
+ A <strong>modern</strong> and self-hosted file upload service that can handle anything you throw at it.
+ </p>
+ <p>
+ With a fast API, chunked file uploads out of the box, beautiful masonry-style file manager and both individual and album sharing capabilities, this little tool was crafted with the best user experience in mind.<br>
+ </p>
+ <div class="mt4" />
+ <Uploader v-if="config.publicMode || (!config.publicMode && loggedIn)" />
+ <div
+ v-else
+ class="has-text-right is-size-4">
+ This site has disabled public uploads. You need an account.
+ </div>
+
+ <Links />
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+<script>
+import { mapState, mapGetters } from 'vuex';
+
+import Logo from '~/components/logo/Logo.vue';
+import Uploader from '~/components/uploader/Uploader.vue';
+import Links from '~/components/home/links/Links.vue';
+
+export default {
+ name: 'Home',
+ components: {
+ Logo,
+ Uploader,
+ Links
+ },
+ data() {
+ return { albums: [] };
+ },
+ computed: {
+ ...mapGetters({ loggedIn: 'auth/isLoggedIn' }),
+ ...mapState(['config'])
+ }
+};
+</script>
+<style lang="scss" scoped>
+ .logoContainer {
+ position: fixed;
+ top: calc(45% - 188px);
+ left: calc(22% - 117px);
+ }
+ .leftSpacer {
+ width: 56%;
+ margin-left: auto;
+ position: relative;
+ .mainBlock {
+ height: calc(100vh - 52px);
+ position: relative;
+ margin: 0 5rem;
+ text-align: right;
+ > div {
+ position: absolute;
+ top: 25%;
+ }
+ }
+ p {
+ font-size: 1.25em;
+ margin-top: 1rem;
+ }
+ strong {
+ text-decoration: underline;
+ }
+ }
+
+ @media (max-width: 1025px) {
+ .logoContainer {
+ position: relative;
+ top: 0;
+ left: 0;
+ text-align: center;
+ }
+ .leftSpacer {
+ width: 100%;
+ .mainBlock {
+ height: auto;
+ padding: 2rem 0;
+ > div {
+ top: 0rem;
+ position: relative;
+ text-align: center;
+ }
+ }
+ }
+ }
+</style>
diff --git a/src/site/pages/login.vue b/src/site/pages/login.vue
new file mode 100644
index 0000000..1974263
--- /dev/null
+++ b/src/site/pages/login.vue
@@ -0,0 +1,156 @@
+<template>
+ <section class="section is-fullheight is-login">
+ <div class="container">
+ <h1 class="title">
+ Dashboard Access
+ </h1>
+ <h2 class="subtitle mb5">
+ Login to access your files and folders
+ </h2>
+ <div class="columns">
+ <div class="column is-4 is-offset-4">
+ <b-field>
+ <b-input
+ v-model="username"
+ class="chibisafe-input"
+ type="text"
+ placeholder="Username"
+ @keyup.enter.native="login" />
+ </b-field>
+ <b-field>
+ <b-input
+ v-model="password"
+ class="chibisafe-input"
+ type="password"
+ placeholder="Password"
+ password-reveal
+ @keyup.enter.native="login" />
+ </b-field>
+
+ <p class="control has-addons is-pulled-right" />
+
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <router-link
+ v-if="config.userAccounts"
+ to="/register"
+ class="is-text">
+ Don't have an account?
+ </router-link>
+ <span v-else>Registration is closed at the moment</span>
+ </div>
+ </div>
+
+ <div class="level-right">
+ <p class="level-item">
+ <b-button
+ size="is-medium"
+ type="is-chibisafe"
+ @click="login">
+ Login
+ </b-button>
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!--
+ <b-modal :active.sync="isMfaModalActive"
+ :canCancel="true"
+ has-modal-card>
+ <div class="card mfa">
+ <div class="card-content">
+ <div class="content">
+ <p>Enter your Two-Factor code to proceed.</p>
+ <b-field>
+ <b-input v-model="mfaCode"
+ placeholder="Your MFA Code"
+ type="text"
+ @keyup.enter.native="mfa"/>
+ <p class="control">
+ <button :class="{ 'is-loading': isLoading }"
+ class="button is-primary"
+ @click="mfa">Submit</button>
+ </p>
+ </b-field>
+ </div>
+ </div>
+ </div>
+ </b-modal>
+ -->
+ </section>
+</template>
+
+<script>
+import { mapState } from 'vuex';
+
+export default {
+ name: 'Login',
+ data() {
+ return {
+ username: null,
+ password: null,
+ mfaCode: null,
+ isMfaModalActive: false,
+ isLoading: false
+ };
+ },
+ computed: mapState(['config', 'auth']),
+ metaInfo() {
+ return { title: 'Login' };
+ },
+ created() {
+ if (this.auth.loggedIn) {
+ this.redirect();
+ }
+ },
+ methods: {
+ async login() {
+ if (this.isLoading) return;
+
+ const { username, password } = this;
+ if (!username || !password) {
+ this.$notifier.error('Please fill both fields before attempting to log in.');
+ return;
+ }
+
+ try {
+ this.isLoading = true;
+ await this.$store.dispatch('auth/login', { username, password });
+ if (this.auth.loggedIn) {
+ this.redirect();
+ }
+ } catch (e) {
+ this.$notifier.error(e.message);
+ } finally {
+ this.isLoading = false;
+ }
+ },
+ /*
+ mfa() {
+ if (!this.mfaCode) return;
+ if (this.isLoading) return;
+ this.isLoading = true;
+ this.axios.post(`${this.$BASE_URL}/login/mfa`, { token: this.mfaCode })
+ .then(res => {
+ this.$store.commit('token', res.data.token);
+ this.redirect();
+ })
+ .catch(err => {
+ this.isLoading = false;
+ this.$onPromiseError(err);
+ });
+ }, */
+ redirect() {
+ if (typeof this.$route.query.redirect !== 'undefined') {
+ this.$router.push(this.$route.query.redirect);
+ return;
+ }
+ this.$router.push('/dashboard');
+ }
+ }
+};
+</script>
diff --git a/src/site/pages/logout.vue b/src/site/pages/logout.vue
new file mode 100644
index 0000000..e6adbea
--- /dev/null
+++ b/src/site/pages/logout.vue
@@ -0,0 +1,8 @@
+<script>
+export default {
+ async created() {
+ await this.$store.dispatch('auth/logout');
+ this.$router.replace('/login');
+ }
+};
+</script>
diff --git a/src/site/pages/register.vue b/src/site/pages/register.vue
new file mode 100644
index 0000000..5cffc54
--- /dev/null
+++ b/src/site/pages/register.vue
@@ -0,0 +1,115 @@
+<template>
+ <section class="section is-fullheight is-register">
+ <div class="container">
+ <h1 class="title">
+ Dashboard Access
+ </h1>
+ <h2 class="subtitle mb5">
+ Register for a new account
+ </h2>
+ <div class="columns">
+ <div class="column is-4 is-offset-4">
+ <b-field>
+ <b-input
+ v-model="username"
+ class="chibisafe-input"
+ type="text"
+ placeholder="Username" />
+ </b-field>
+ <b-field>
+ <b-input
+ v-model="password"
+ class="chibisafe-input"
+ type="password"
+ placeholder="Password"
+ password-reveal />
+ </b-field>
+ <b-field>
+ <b-input
+ v-model="rePassword"
+ class="chibisafe-input"
+ type="password"
+ placeholder="Re-type Password"
+ password-reveal
+ @keyup.enter.native="register" />
+ </b-field>
+
+ <div class="level">
+ <!-- Left side -->
+ <div class="level-left">
+ <div class="level-item">
+ <router-link
+ to="/login"
+ class="is-text">
+ Already have an account?
+ </router-link>
+ </div>
+ </div>
+ <!-- Right side -->
+ <div class="level-right">
+ <p class="level-item">
+ <b-button
+ size="is-medium"
+ type="is-chibisafe"
+ :disabled="isLoading"
+ @click="register">
+ Register
+ </b-button>
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import { mapState } from 'vuex';
+
+export default {
+ name: 'Register',
+ data() {
+ return {
+ username: null,
+ password: null,
+ rePassword: null,
+ isLoading: false
+ };
+ },
+ computed: mapState(['config', 'auth']),
+ metaInfo() {
+ return { title: 'Register' };
+ },
+ methods: {
+ async register() {
+ if (this.isLoading) return;
+
+ if (!this.username || !this.password || !this.rePassword) {
+ this.$notifier.error('Please fill all fields before attempting to register.');
+ return;
+ }
+ if (this.password !== this.rePassword) {
+ this.$notifier.error('Passwords don\'t match');
+ return;
+ }
+ this.isLoading = true;
+
+ try {
+ const response = await this.$store.dispatch('auth/register', {
+ username: this.username,
+ password: this.password
+ });
+
+ this.$notifier.success(response.message);
+ this.$router.push('/login');
+ return;
+ } catch (error) {
+ this.$notifier.error(error.message);
+ } finally {
+ this.isLoading = false;
+ }
+ }
+ }
+};
+</script>
diff --git a/src/site/plugins/axios.js b/src/site/plugins/axios.js
new file mode 100644
index 0000000..1c54488
--- /dev/null
+++ b/src/site/plugins/axios.js
@@ -0,0 +1,32 @@
+export default function({ $axios, store }) {
+ $axios.setHeader('accept', 'application/vnd.chibisafe.json');
+
+ $axios.onRequest(config => {
+ if (store.state.auth.token) {
+ config.headers.common.Authorization = `bearer ${store.state.auth.token}`;
+ }
+ });
+
+ $axios.onError(error => {
+ if (process.env.NODE_ENV !== 'production') console.error('[AXIOS Error]', error);
+ if (process.browser) {
+ if (process.env.NODE_ENV !== 'production') {
+ if (error.response?.data?.message) {
+ store.dispatch('alert/set', {
+ text: error.response.data.message,
+ error: true
+ });
+ } else {
+ store.dispatch('alert/set', {
+ text: `[AXIOS]: ${error.message}`,
+ error: true
+ });
+ }
+ }
+
+ /* if (error.response?.data?.message.indexOf('Token expired') !== -1) {
+ store.dispatch('auth/logout');
+ } */
+ }
+ });
+}
diff --git a/src/site/plugins/buefy.js b/src/site/plugins/buefy.js
new file mode 100644
index 0000000..f3f7552
--- /dev/null
+++ b/src/site/plugins/buefy.js
@@ -0,0 +1,5 @@
+import Vue from 'vue';
+import Buefy from 'buefy';
+// import 'buefy/dist/buefy.css';
+
+Vue.use(Buefy);
diff --git a/src/site/plugins/handler.js b/src/site/plugins/handler.js
new file mode 100644
index 0000000..7933eab
--- /dev/null
+++ b/src/site/plugins/handler.js
@@ -0,0 +1,25 @@
+import AlertTypes from '~/constants/alertTypes';
+
+export default ({ store }, inject) => {
+ inject('handler', {
+ async executeAction(action, param) {
+ try {
+ const response = await store.dispatch(action, param);
+
+ store.commit('alert/set', {
+ message: response?.message ?? 'Executed sucesfully',
+ type: AlertTypes.SUCCESS
+ });
+
+ return response;
+ } catch (e) {
+ store.commit('alert/set', {
+ message: e.message,
+ type: AlertTypes.ERROR
+ });
+
+ return null;
+ }
+ }
+ });
+};
diff --git a/src/site/plugins/notifier.js b/src/site/plugins/notifier.js
new file mode 100644
index 0000000..465df6d
--- /dev/null
+++ b/src/site/plugins/notifier.js
@@ -0,0 +1,25 @@
+import AlertTypes from '~/constants/alertTypes';
+
+export default ({ store }, inject) => {
+ inject('notifier', {
+ showMessage({ message = '', type = '', snackbar = false }) {
+ store.commit('alert/set', { message, type, snackbar });
+ },
+ message(message, snackbar) {
+ this.showMessage({ message, type: AlertTypes.PRIMARY, snackbar });
+ },
+ info(message, snackbar) {
+ this.showMessage({ message, type: AlertTypes.INFO, snackbar });
+ },
+ warning(message, snackbar) {
+ this.showMessage({ message, type: AlertTypes.WARNING, snackbar });
+ },
+ success(message, snackbar) {
+ this.showMessage({ message, type: AlertTypes.SUCCESS, snackbar });
+ },
+ error(message, snackbar) {
+ this.showMessage({ message, type: AlertTypes.ERROR, snackbar });
+ },
+ types: AlertTypes
+ });
+};
diff --git a/src/site/plugins/nuxt-client-init.js b/src/site/plugins/nuxt-client-init.js
new file mode 100644
index 0000000..4b10dcd
--- /dev/null
+++ b/src/site/plugins/nuxt-client-init.js
@@ -0,0 +1,3 @@
+export default async ctx => {
+ await ctx.store.dispatch('nuxtClientInit', ctx);
+};
diff --git a/src/site/plugins/v-clipboard.js b/src/site/plugins/v-clipboard.js
new file mode 100644
index 0000000..f1f6b53
--- /dev/null
+++ b/src/site/plugins/v-clipboard.js
@@ -0,0 +1,4 @@
+import Vue from 'vue';
+import Clipboard from 'v-clipboard';
+
+Vue.use(Clipboard);
diff --git a/src/site/plugins/vue-isyourpasswordsafe.js b/src/site/plugins/vue-isyourpasswordsafe.js
new file mode 100644
index 0000000..6172ca0
--- /dev/null
+++ b/src/site/plugins/vue-isyourpasswordsafe.js
@@ -0,0 +1,7 @@
+import Vue from 'vue';
+import VueIsYourPasswordSafe from 'vue-isyourpasswordsafe';
+
+Vue.use(VueIsYourPasswordSafe, {
+ minLength: 6,
+ maxLength: 64
+});
diff --git a/src/site/plugins/vue-timeago.js b/src/site/plugins/vue-timeago.js
new file mode 100644
index 0000000..28f3c6d
--- /dev/null
+++ b/src/site/plugins/vue-timeago.js
@@ -0,0 +1,8 @@
+import Vue from 'vue';
+import VueTimeago from 'vue-timeago';
+
+Vue.use(VueTimeago, {
+ name: 'timeago',
+ locale: 'en-US',
+ locales: { 'en-US': require('vue-timeago/locales/en-US.json') }
+});
diff --git a/src/site/plugins/vuebar.js b/src/site/plugins/vuebar.js
new file mode 100644
index 0000000..18dd4e5
--- /dev/null
+++ b/src/site/plugins/vuebar.js
@@ -0,0 +1,4 @@
+import Vue from 'vue';
+import Vuebar from 'vuebar';
+
+Vue.use(Vuebar);
diff --git a/src/site/static/oembed.json b/src/site/static/oembed.json
new file mode 100644
index 0000000..fed6ff9
--- /dev/null
+++ b/src/site/static/oembed.json
@@ -0,0 +1,6 @@
+{
+ "title": "chibisafe",
+ "author_name": "Blazing fast file uploader and bunker written in node! 🚀",
+ "provider_name": "chibisafe",
+ "provider_url": "https://chibisafe.moe"
+}
diff --git a/src/site/store/.eslintrc.json b/src/site/store/.eslintrc.json
new file mode 100644
index 0000000..052e3ef
--- /dev/null
+++ b/src/site/store/.eslintrc.json
@@ -0,0 +1,5 @@
+{
+ "rules": {
+ "no-shadow": ["error", { "allow": ["state"] }]
+ }
+}
diff --git a/src/site/store/admin.js b/src/site/store/admin.js
new file mode 100644
index 0000000..58b63b5
--- /dev/null
+++ b/src/site/store/admin.js
@@ -0,0 +1,122 @@
+export const state = () => ({
+ users: [],
+ user: {
+ id: null,
+ username: null,
+ enabled: false,
+ createdAt: null,
+ editedAt: null,
+ apiKeyEditedAt: null,
+ isAdmin: null,
+ files: []
+ },
+ file: {},
+ settings: {}
+});
+
+export const actions = {
+ async fetchSettings({ commit }) {
+ const response = await this.$axios.$get('service/config');
+ commit('setSettings', response);
+
+ return response;
+ },
+ async fetchUsers({ commit }) {
+ const response = await this.$axios.$get('admin/users');
+ commit('setUsers', response);
+
+ return response;
+ },
+ async fetchUser({ commit }, id) {
+ const response = await this.$axios.$get(`admin/users/${id}`);
+ commit('setUserInfo', response);
+
+ return response;
+ },
+ async fetchFile({ commit }, id) {
+ const response = await this.$axios.$get(`admin/file/${id}`);
+ commit('setFile', response);
+ commit('setUserInfo', response);
+
+ return response;
+ },
+ async banIP(_, ip) {
+ const response = await this.$axios.$post('admin/ban/ip', { ip });
+
+ return response;
+ },
+ async enableUser({ commit }, id) {
+ const response = await this.$axios.$post('admin/users/enable', { id });
+
+ commit('changeUserState', { userId: id, enabled: true });
+
+ return response;
+ },
+ async disableUser({ commit }, id) {
+ const response = await this.$axios.$post('admin/users/disable', { id });
+
+ commit('changeUserState', { userId: id, enabled: false });
+
+ return response;
+ },
+ async promoteUser({ commit }, id) {
+ const response = await this.$axios.$post('admin/users/promote', { id });
+
+ commit('changeUserState', { userId: id, isAdmin: true });
+
+ return response;
+ },
+ async demoteUser({ commit }, id) {
+ const response = await this.$axios.$post('admin/users/demote', { id });
+
+ commit('changeUserState', { userId: id, isAdmin: false });
+
+ return response;
+ },
+ async purgeUserFiles(_, id) {
+ const response = await this.$axios.$post('admin/users/purge', { id });
+
+ return response;
+ },
+ async restartService() {
+ const response = await this.$axios.$post('service/restart');
+
+ return response;
+ }
+};
+
+export const mutations = {
+ setSettings(state, { config }) {
+ state.settings = config;
+ },
+ setUsers(state, { users }) {
+ state.users = users;
+ },
+ setUserInfo(state, { user, files }) {
+ state.user = { ...state.user, ...user };
+ state.user.files = files || [];
+ },
+ setFile(state, { file }) {
+ state.file = file || {};
+ },
+ changeUserState(state, { userId, enabled, isAdmin }) {
+ const foundIndex = state.users.findIndex(({ id }) => id === userId);
+ if (foundIndex > -1) {
+ if (enabled !== undefined) {
+ state.users[foundIndex].enabled = enabled;
+ }
+ if (isAdmin !== undefined) {
+ state.users[foundIndex].isAdmin = isAdmin;
+ }
+ }
+
+ if (state.user.id === userId) {
+ if (enabled !== undefined) {
+ state.user.enabled = enabled;
+ }
+ if (isAdmin !== undefined) {
+ state.user.isAdmin = isAdmin;
+ }
+ }
+ }
+};
diff --git a/src/site/store/albums.js b/src/site/store/albums.js
new file mode 100644
index 0000000..bbd2db6
--- /dev/null
+++ b/src/site/store/albums.js
@@ -0,0 +1,148 @@
+import Vue from 'vue';
+
+export const state = () => ({
+ list: [],
+ isListLoading: false,
+ albumDetails: {},
+ expandedAlbums: [],
+ tinyDetails: []
+});
+
+export const getters = {
+ isExpanded: state => id => state.expandedAlbums.indexOf(id) > -1,
+ getDetails: state => id => state.albumDetails[id] || {}
+};
+
+export const actions = {
+ async fetch({ commit }) {
+ commit('albumsRequest');
+ const response = await this.$axios.$get('albums/mini');
+
+ commit('setAlbums', response.albums);
+
+ return response;
+ },
+ async fetchDetails({ commit }, albumId) {
+ const response = await this.$axios.$get(`album/${albumId}/links`);
+
+ commit('setDetails', {
+ id: albumId,
+ details: {
+ links: response.links
+ }
+ });
+
+ return response;
+ },
+ async createAlbum({ commit }, name) {
+ const response = await this.$axios.$post('album/new', { name });
+
+ commit('addAlbum', response.data);
+
+ return response;
+ },
+ async deleteAlbum({ commit }, albumId) {
+ const response = await this.$axios.$delete(`album/${albumId}`);
+
+ commit('removeAlbum', albumId);
+
+ return response;
+ },
+ async createLink({ commit }, albumId) {
+ const response = await this.$axios.$post('album/link/new', { albumId });
+
+ commit('addAlbumLink', { albumId, data: response.data });
+
+ return response;
+ },
+ async createCustomLink({ commit }, { albumId, value }) {
+ const response = await this.$axios.$post('album/link/new', { albumId, identifier: value });
+
+ commit('addAlbumLink', { albumId, data: response.data });
+
+ return response;
+ },
+ async updateLinkOptions({ commit }, { albumId, linkOpts }) {
+ const response = await this.$axios.$post('album/link/edit', {
+ identifier: linkOpts.identifier,
+ enableDownload: linkOpts.enableDownload,
+ enabled: linkOpts.enabled
+ });
+
+ commit('updateAlbumLinkOpts', { albumId, linkOpts: response.data });
+
+ return response;
+ },
+ async toggleNsfw({ commit }, { albumId, nsfw }) {
+ const response = await this.$axios.$post('album/edit', {
+ id: albumId,
+ nsfw
+ });
+ commit('updateNsfw', { albumId, nsfw });
+
+ return response;
+ },
+ async deleteLink({ commit }, { albumId, identifier }) {
+ const response = await this.$axios.$delete(`album/link/delete/${identifier}`);
+
+ commit('removeAlbumLink', { albumId, identifier });
+
+ return response;
+ },
+ async getTinyDetails({ commit }) {
+ const response = await this.$axios.$get('albums/dropdown');
+
+ commit('setTinyDetails', response);
+
+ return response;
+ }
+};
+
+export const mutations = {
+ albumsRequest(state) {
+ state.isLoading = true;
+ },
+ setAlbums(state, albums) {
+ state.list = albums;
+ state.isLoading = false;
+ },
+ addAlbum(state, album) {
+ state.list.unshift(album);
+ },
+ removeAlbum(state, albumId) {
+ // state.list = state.list.filter(({ id }) => id !== albumId);
+ const foundIndex = state.list.findIndex(({ id }) => id === albumId);
+ state.list.splice(foundIndex, 1);
+ },
+ setDetails(state, { id, details }) {
+ Vue.set(state.albumDetails, id, details);
+ },
+ addAlbumLink(state, { albumId, data }) {
+ state.albumDetails[albumId].links.push(data);
+ },
+ updateAlbumLinkOpts(state, { albumId, linkOpts }) {
+ const foundIndex = state.albumDetails[albumId].links.findIndex(
+ ({ identifier }) => identifier === linkOpts.identifier
+ );
+ const link = state.albumDetails[albumId].links[foundIndex];
+ state.albumDetails[albumId].links[foundIndex] = { ...link, ...linkOpts };
+ },
+ updateNsfw(state, { albumId, nsfw }) {
+ state.list.find(el => el.id === albumId).nsfw = nsfw;
+ },
+ removeAlbumLink(state, { albumId, identifier }) {
+ const foundIndex = state.albumDetails[albumId].links.findIndex(({ identifier: id }) => id === identifier);
+ if (foundIndex > -1) state.albumDetails[albumId].links.splice(foundIndex, 1);
+ },
+ toggleExpandedState(state, id) {
+ const foundIndex = state.expandedAlbums.indexOf(id);
+ if (foundIndex > -1) {
+ state.expandedAlbums.splice(foundIndex, 1);
+ } else {
+ state.expandedAlbums.push(id);
+ }
+ },
+ setTinyDetails(state, { albums }) {
+ state.tinyDetails = albums;
+ }
+};
diff --git a/src/site/store/alert.js b/src/site/store/alert.js
new file mode 100644
index 0000000..cbd6359
--- /dev/null
+++ b/src/site/store/alert.js
@@ -0,0 +1,33 @@
+import AlertTypes from '~/constants/alertTypes';
+
+const getDefaultState = () => ({
+ message: null,
+ type: null,
+ snackbar: false
+});
+
+export const state = getDefaultState;
+
+export const actions = {
+ set({ commit }, data) {
+ // Only exists for backwards compatibility, remove one day
+ if (data.error === true) data.type = AlertTypes.ERROR;
+ if (data.text !== undefined) data.message = data.text;
+
+ commit('set', data);
+ },
+ clear({ commit }) {
+ commit('clear');
+ }
+};
+
+export const mutations = {
+ set(state, { message, type, snackbar }) {
+ state.message = message;
+ state.type = type;
+ state.snackbar = snackbar || false;
+ },
+ clear(state) {
+ Object.assign(state, getDefaultState());
+ }
+};
diff --git a/src/site/store/auth.js b/src/site/store/auth.js
new file mode 100644
index 0000000..51a79d6
--- /dev/null
+++ b/src/site/store/auth.js
@@ -0,0 +1,106 @@
+const getDefaultState = () => ({
+ loggedIn: false,
+ user: {
+ id: null,
+ isAdmin: false,
+ username: null
+ },
+ token: null
+});
+
+export const state = getDefaultState;
+
+export const getters = {
+ isLoggedIn: state => state.loggedIn,
+ getApiKey: state => state.user?.apiKey,
+ getToken: state => state.token
+};
+
+export const actions = {
+ async verify({ commit, dispatch }) {
+ try {
+ const response = await this.$axios.$get('verify');
+ commit('loginSuccess', response);
+ } catch (e) {
+ dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+ },
+ async login({ commit }, { username, password }) {
+ commit('loginRequest');
+
+ const data = await this.$axios.$post('auth/login', { username, password });
+ this.$axios.setToken(data.token, 'Bearer');
+
+ commit('setToken', data.token);
+ commit('loginSuccess', { token: data.token, user: data.user });
+ },
+ async register(_, { username, password }) {
+ return this.$axios.$post('auth/register', {
+ username,
+ password
+ });
+ },
+ async fetchCurrentUser({ commit, dispatch }) {
+ try {
+ const data = await this.$axios.$get('users/me');
+ commit('setUser', data.user);
+ } catch (e) {
+ dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+ },
+ async changePassword({ dispatch }, { password, newPassword }) {
+ try {
+ const response = await this.$axios.$post('user/password/change', {
+ password,
+ newPassword
+ });
+
+ return response;
+ } catch (e) {
+ dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+
+ return null;
+ },
+ async requestAPIKey({ commit, dispatch }) {
+ try {
+ const response = await this.$axios.$post('user/apikey/change');
+ commit('setApiKey', response.apiKey);
+
+ return response;
+ } catch (e) {
+ dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+
+ return null;
+ },
+ logout({ commit }) {
+ commit('logout');
+ }
+};
+
+export const mutations = {
+ setToken(state, token) {
+ state.token = token;
+ },
+ setApiKey(state, apiKey) {
+ state.user.apiKey = apiKey;
+ },
+ setUser(state, user) {
+ state.user = user;
+ },
+ loginRequest(state) {
+ state.isLoading = true;
+ },
+ loginSuccess(state, { user }) {
+ this.$cookies.set('token', state.token, { path: '/' });
+ state.user = user;
+ state.loggedIn = true;
+ state.isLoading = false;
+ },
+ logout(state) {
+ this.$cookies.remove('token', { path: '/' });
+ // reset state to default
+ Object.assign(state, getDefaultState());
+ }
+};
diff --git a/src/site/store/config.js b/src/site/store/config.js
new file mode 100644
index 0000000..c17632d
--- /dev/null
+++ b/src/site/store/config.js
@@ -0,0 +1,18 @@
+export const state = () => ({
+ development: true,
+ version: '4.0.0',
+ URL: 'http://localhost:8080',
+ baseURL: 'http://localhost:8080/api',
+ serviceName: '',
+ maxFileSize: 100,
+ chunkSize: 90,
+ maxLinksPerAlbum: 5,
+ publicMode: false,
+ userAccounts: false
+});
+
+export const mutations = {
+ set(state, config) {
+ Object.assign(state, config);
+ }
+};
diff --git a/src/site/store/images.js b/src/site/store/images.js
new file mode 100644
index 0000000..535e7cd
--- /dev/null
+++ b/src/site/store/images.js
@@ -0,0 +1,193 @@
+import Vue from 'vue';
+
+export const getDefaultState = () => ({
+ files: [],
+ isLoading: false,
+ pagination: {
+ page: 1,
+ limit: 50,
+ totalFiles: 0
+ },
+ search: '',
+ showList: false,
+ albumName: null,
+ albumDownloadEnabled: false,
+ fileExtraInfoMap: {}, // information about the selected file
+ fileAlbumsMap: {}, // map of file ids with a list of album objects the file is in
+ fileTagsMap: {} // map of file ids with a list of tag objects for the file
+});
+
+export const state = getDefaultState;
+
+export const getters = {
+ getTotalFiles: ({ pagination }) => pagination.totalFiles,
+ getFetchedCount: ({ files }) => files.length,
+ shouldPaginate: ({ pagination }) => pagination.totalFiles > pagination.limit,
+ getLimit: ({ pagination }) => pagination.limit,
+ getName: ({ name }) => name
+};
+
+export const actions = {
+ async fetch({ commit, dispatch, state }, page) {
+ commit('setIsLoading');
+
+ page = page || 1;
+
+ try {
+ const response = await this.$axios.$get('files', { params: { limit: state.pagination.limit, page } });
+
+ commit('setFilesAndMeta', { ...response, page });
+
+ return response;
+ } catch (e) {
+ dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+
+ return null;
+ },
+ async fetchByAlbumId({ commit, state }, { id, page }) {
+ commit('setIsLoading');
+
+ page = page || 1;
+
+ const response = await this.$axios.$get(`album/${id}/full`, {
+ params: { limit: state.pagination.limit, page }
+ });
+
+ commit('setFilesAndMeta', { ...response, page });
+
+ return response;
+ },
+ async fetchFileMeta({ commit }, fileId) {
+ const response = await this.$axios.$get(`file/${fileId}`);
+
+ commit('setFileAlbums', { ...response, fileId });
+ commit('setFileTags', { ...response, fileId });
+ commit('setFileExtraInfo', { ...response, fileId });
+
+ return response;
+ },
+ async getFileAlbums({ commit }, fileId) {
+ const response = await this.$axios.$get(`file/${fileId}/albums`);
+
+ commit('setFileAlbums', { ...response, fileId });
+
+ return response;
+ },
+ async addToAlbum({ commit }, { fileId, albumId }) {
+ const response = await this.$axios.$post('file/album/add', { fileId, albumId });
+
+ commit('addAlbumToFile', { fileId, albumId, ...response.data });
+
+ return response;
+ },
+ async removeFromAlbum({ commit }, { fileId, albumId }) {
+ const response = await this.$axios.$post('file/album/del', { fileId, albumId });
+
+ commit('removeAlbumFromFile', { fileId, albumId });
+
+ return response;
+ },
+ async deleteFile({ commit }, fileId) {
+ const response = await this.$axios.$delete(`file/${fileId}`);
+
+ commit('removeFile', fileId);
+
+ return response;
+ },
+ async addTag({ commit }, { fileId, tagName }) {
+ const response = await this.$axios.$post('file/tag/add', { fileId, tagName });
+
+ commit('addTagToFile', response.data);
+
+ return response;
+ },
+ async removeTag({ commit }, { fileId, tagName }) {
+ const response = await this.$axios.$post('file/tag/del', { fileId, tagName });
+
+ commit('removeTagFromFile', response.data);
+
+ return response;
+ },
+ async search({ commit, dispatch }, { q, albumId, page }) {
+ const optionalAlbum = albumId ? `&albumId=${albumId}` : '';
+
+ page = page || 1;
+
+ try {
+ const response = await this.$axios.$get(`search/?q=${encodeURI(q)}${optionalAlbum}`);
+
+ commit('setFilesAndMeta', { ...response, page });
+
+ return response;
+ } catch (e) {
+ dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+
+ return null;
+ }
+};
+
+export const mutations = {
+ setIsLoading(state) {
+ state.isLoading = true;
+ },
+ setFilesAndMeta(state, {
+ files, name, page, count, downloadEnabled
+ }) {
+ state.files = files || [];
+ state.albumName = name ?? null;
+ state.downloadEnabled = downloadEnabled ?? false;
+ state.isLoading = false;
+ state.pagination.page = page || 1;
+ state.pagination.totalFiles = count || 0;
+ },
+ removeFile(state, fileId) {
+ const foundIndex = state.files.findIndex(({ id }) => id === fileId);
+ if (foundIndex > -1) {
+ state.files.splice(foundIndex, 1);
+ state.pagination.totalFiles -= 1;
+ }
+ },
+ setFileAlbums(state, { fileId, albums }) {
+ Vue.set(state.fileAlbumsMap, fileId, albums);
+ },
+ setFileTags(state, { fileId, tags }) {
+ Vue.set(state.fileTagsMap, fileId, tags);
+ },
+ setFileExtraInfo(state, { fileId, file }) {
+ Vue.set(state.fileExtraInfoMap, fileId, file);
+ },
+ addAlbumToFile(state, { fileId, album }) {
+ if (!state.fileAlbumsMap[fileId]) return;
+
+ state.fileAlbumsMap[fileId].push(album);
+ },
+ removeAlbumFromFile(state, { fileId, albumId }) {
+ if (!state.fileAlbumsMap[fileId]) return;
+
+ const foundIndex = state.fileAlbumsMap[fileId].findIndex(({ id }) => id === albumId);
+ if (foundIndex > -1) {
+ state.fileAlbumsMap[fileId].splice(foundIndex, 1);
+ }
+ },
+ addTagToFile(state, { fileId, tag }) {
+ if (!state.fileTagsMap[fileId]) return;
+
+ state.fileTagsMap[fileId].push(tag);
+ },
+ removeTagFromFile(state, { fileId, tag }) {
+ if (!state.fileTagsMap[fileId]) return;
+
+ const foundIndex = state.fileTagsMap[fileId].findIndex(({ id }) => id === tag.id);
+ if (foundIndex > -1) {
+ state.fileTagsMap[fileId].splice(foundIndex, 1);
+ }
+ },
+ setShowList(state, showList) {
+ state.showList = showList;
+ },
+ resetState(state) {
+ Object.assign(state, getDefaultState());
+ }
+};
diff --git a/src/site/store/index.js b/src/site/store/index.js
new file mode 100644
index 0000000..94d673f
--- /dev/null
+++ b/src/site/store/index.js
@@ -0,0 +1,13 @@
+import config from '../../../dist/config.json';
+
+export const actions = {
+ async nuxtClientInit({ commit, dispatch }) {
+ commit('config/set', config);
+
+ const cookies = this.$cookies.getAll();
+ if (!cookies.token) return dispatch('auth/logout');
+
+ commit('auth/setToken', cookies.token);
+ return dispatch('auth/verify');
+ }
+};
diff --git a/src/site/store/tags.js b/src/site/store/tags.js
new file mode 100644
index 0000000..a28c2ad
--- /dev/null
+++ b/src/site/store/tags.js
@@ -0,0 +1,40 @@
+export const state = () => ({
+ tagsList: []
+});
+
+export const actions = {
+ async fetch({ commit }) {
+ const response = await this.$axios.$get('tags');
+
+ commit('setTags', response.tags);
+
+ return response;
+ },
+ async createTag({ commit }, name) {
+ const response = await this.$axios.$post('tag/new', { name });
+
+ commit('addTag', response.data);
+
+ return response;
+ },
+ async deleteTag({ commit }, tagId) {
+ const response = await this.$axios.$delete(`tag/${tagId}`);
+
+ commit('deleteTag', response.data);
+
+ return response;
+ }
+};
+
+export const mutations = {
+ setTags(state, tags) {
+ state.tagsList = tags;
+ },
+ addTag(state, tag) {
+ state.tagsList.unshift(tag);
+ },
+ deleteTag(state, { id: tagId }) {
+ const foundIndex = state.tagsList.findIndex(({ id }) => id === tagId);
+ state.tagsList.splice(foundIndex, 1);
+ }
+};
diff --git a/src/tests/api/verify.test.js b/src/tests/api/verify.test.js
new file mode 100644
index 0000000..34bc45e
--- /dev/null
+++ b/src/tests/api/verify.test.js
@@ -0,0 +1,12 @@
+/* eslint-disable no-undef */
+import { axios } from '../utils';
+
+// This should never succeed as we are not passing a token. We are expecting a 401
+test('Verify token', async () => {
+ try {
+ await axios.get('/api/verify');
+ expect(true).toBe(false);
+ } catch (err) {
+ expect(err.response.status).toBe(401);
+ }
+});
diff --git a/src/tests/e2e/index.test.js b/src/tests/e2e/index.test.js
new file mode 100644
index 0000000..ca45efd
--- /dev/null
+++ b/src/tests/e2e/index.test.js
@@ -0,0 +1,20 @@
+/* eslint-disable no-undef */
+const puppeteer = require('puppeteer');
+
+test('Check the logo renders', async () => {
+ const browser = await puppeteer.launch({
+ headless: true
+ });
+ const page = await browser.newPage();
+
+ await page.goto('http://localhost:5000', {
+ timeout: 45000,
+ waitUntil: ['networkidle2']
+ });
+
+ // Check the logo exists
+ const logo = await page.waitForSelector('.logoContainer');
+ await browser.close();
+
+ expect(logo).toBeTruthy();
+});
diff --git a/src/tests/utils.js b/src/tests/utils.js
new file mode 100644
index 0000000..3634ed9
--- /dev/null
+++ b/src/tests/utils.js
@@ -0,0 +1,11 @@
+const axios = require('axios');
+const instance = axios.create({
+ baseURL: 'http://localhost:5000',
+ headers: {
+ common: {
+ accept: 'application/vnd.chibisafe.json'
+ }
+ }
+});
+
+module.exports.axios = instance;