Merge pull request #228 from Zephyrrus/begone_trailing_commas

Merge own dev branch into main dev branch
This commit is contained in:
Kana 2020-12-24 21:41:24 +09:00 committed by GitHub
commit 2412a60bd4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
114 changed files with 23204 additions and 2268 deletions

11
.prettierrc Normal file
View File

@ -0,0 +1,11 @@
{
"trailingComma": "none",
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"endOfLine": "lf",
"bracketSpacing": true,
"useTabs": true,
"printWidth": 120,
"jsxBracketSameLine": false
}

50
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,50 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "[DEV] Launch API",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}\\src\\api\\structures\\Server"
},
{
"type": "node",
"request": "launch",
"name": "[DEV] Launch ThumbGen",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}\\src\\api\\generateThumbs"
},
{
"type": "chrome",
"request": "launch",
"name": "client: chrome",
"url": "http://localhost:8070",
"webRoot": "${workspaceFolder}"
},
{
"type": "node",
"request": "launch",
"name": "server: nuxt",
"args": ["dev"],
"osx": {
"program": "${workspaceFolder}/node_modules/.bin/nuxt"
},
"linux": {
"program": "${workspaceFolder}/node_modules/.bin/nuxt"
},
"windows": {
"program": "${workspaceFolder}/node_modules/nuxt/bin/nuxt.js"
}
}
],
"compounds": [
{
"name": "fullstack: nuxt",
"configurations": ["server: nuxt", "client: chrome"]
}
]
}

29
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,29 @@
{
"discord.enabled": true,
"editor.detectIndentation": false,
"editor.insertSpaces": false,
"files.insertFinalNewline": true,
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"vetur.validation.template": false,
"[javascript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[vue]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"prettier.disableLanguages": ["vue"],
"vetur.format.enable": true,
"files.eol": "\n",
"vetur.format.defaultFormatter.html": "js-beautify-html",
"vetur.format.defaultFormatter.js": "prettier",
"vetur.format.defaultFormatter.scss": "prettier",
"vetur.format.defaultFormatter.stylus": "stylus-supremacy",
"vetur.format.defaultFormatter.ts": "vscode-typescript",
"eslint.alwaysShowStatus": true,
"eslint.format.enable": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
}

13
TODO
View File

@ -1,12 +1,11 @@
- There's a vertical scrollbar on seemingly all pages even though there is no need for it
- Uploaded text file (via ShareX) does not show up in the dashboard's "Files" area
* Currently only pictures show up on the dashboard due to having thumbs
- When you delete a file from the grid view, it's not removed from the list
- When you are on the table view of files, the column `Albums` doesn't get populated unless you fetch them, obviously.
- Think of a strategy to achieve this in a nice manner
- Can't delete/rename albums when going into album view. Or ever.
- Dark theme the annoying popups for deleting and other stuff
- Logout button
- Finish /dashboard/tags - right now all it does is ask if you want to delete a tag (make it like the albums page)
- Make the settings page work properly
- Add an stats page that displays statistics about the server like free space, free/used memory, etc
- Add statistics of total disk space used by a specific user (?)
- Page that lists all files ordered by size, useful to find big files

View File

@ -19,5 +19,5 @@ module.exports = {
seeds: {
directory: 'src/api/database/seeds'
},
useNullAsDefault: process.env.DB_CLIENT === 'sqlite3' ? true : false
useNullAsDefault: process.env.DB_CLIENT === 'sqlite3'
};

View File

@ -11,8 +11,8 @@ const clientConfig = {
maxFileSize: parseInt(process.env.MAX_SIZE, 10),
chunkSize: parseInt(process.env.CHUNK_SIZE, 10),
maxLinksPerAlbum: parseInt(process.env.MAX_LINKS_PER_ALBUM, 10),
publicMode: process.env.PUBLIC_MODE === 'true' ? true : false,
userAccounts: process.env.USER_ACCOUNTS === 'true' ? true : false
publicMode: process.env.PUBLIC_MODE === 'true',
userAccounts: process.env.USER_ACCOUNTS === 'true'
};
export default {
@ -31,7 +31,11 @@ export default {
{ hid: 'theme-color', name: 'theme-color', content: `${process.env.META_THEME_COLOR}` },
{ hid: 'description', name: 'description', content: `${process.env.META_DESCRIPTION}` },
{ hid: 'keywords', name: 'keywords', content: `${process.env.META_KEYWORDS}` },
{ hid: 'apple-mobile-web-app-title', name: 'apple-mobile-web-app-title', content: `${process.env.SERVICE_NAME}` },
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: `${process.env.SERVICE_NAME}`
},
{ hid: 'application-name', name: 'application-name', content: `${process.env.SERVICE_NAME}` },
// { hid: 'msapplication-config', name: 'msapplication-config', content: `${process.env.DOMAIN}/browserconfig.xml` },
{ hid: 'twitter:card', name: 'twitter:card', content: 'summary_large_image' },
@ -63,28 +67,34 @@ export default {
'~/plugins/vue-timeago',
'~/plugins/flexsearch',
'~/plugins/vuebar',
'~/plugins/nuxt-client-init'
'~/plugins/nuxt-client-init',
'~/plugins/notifier',
'~/plugins/handler'
],
css: [],
modules: [
'@nuxtjs/axios',
'cookie-universal-nuxt'
],
modules: ['@nuxtjs/axios', 'cookie-universal-nuxt'],
router: {
linkActiveClass: 'is-active',
linkExactActiveClass: 'is-active'
},
axios: {
baseURL: `${process.env.DOMAIN}${process.env.ROUTE_PREFIX}`
},
build: {
extractCSS: true,
extractCSS: process.env.NODE_ENV === 'production',
postcss: {
preset: {
autoprefixer
}
},
extend(config, { isClient }) {
extend(config, { isClient, isDev }) {
// Extend only webpack config for client-bundle
if (isClient) {
jetpack.write('dist/config.json', clientConfig);
}
if (isDev) {
config.devtool = isClient ? 'source-map' : 'inline-source-map';
}
}
}
};

15734
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -30,20 +30,25 @@
"node": ">=12.0.0"
},
"dependencies": {
"@mdi/font": "^5.3.45",
"@nuxtjs/axios": "^5.4.1",
"adm-zip": "^0.4.13",
"bcrypt": "^4.0.1",
"body-parser": "^1.18.3",
"buefy": "0.8.5",
"buefy": "^0.9.4",
"busboy": "^0.2.14",
"chalk": "^2.4.1",
"chrono-node": "^2.1.4",
"compression": "^1.7.2",
"cookie-universal-nuxt": "^2.0.14",
"cors": "^2.8.5",
"dotenv": "^6.2.0",
"dumper.js": "^1.3.1",
"express": "^4.16.4",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.4",
"express": "^4.17.1",
"express-rate-limit": "^3.4.0",
"ffmpeg-generate-video-preview": "^1.0.3",
"file-saver": "^2.0.1",
"flexsearch": "^0.6.22",
"fluent-ffmpeg": "^2.1.2",
@ -52,7 +57,9 @@
"imagesloaded": "^4.1.4",
"jsonwebtoken": "^8.5.0",
"knex": "^0.16.3",
"masonry-layout": "^4.2.2",
"moment": "^2.24.0",
"morgan": "^1.10.0",
"multer": "^1.4.1",
"mysql": "^2.16.0",
"nuxt": "2.12.2",
@ -62,6 +69,7 @@
"pg": "^7.8.1",
"qoa": "^0.2.0",
"randomstring": "^1.1.5",
"search-query-parser": "^1.5.5",
"serve-static": "^1.13.2",
"sharp": "^0.25.2",
"sqlite3": "^4.0.6",
@ -75,26 +83,114 @@
"vuebar": "^0.0.20"
},
"devDependencies": {
"@creativebulma/bulma-divider": "^1.1.0",
"@nuxtjs/eslint-config": "^3.0.0",
"autoprefixer": "^9.4.7",
"babel-eslint": "^10.0.1",
"cross-env": "^5.2.0",
"eslint": "^5.13.0",
"eslint-config-airbnb-base": "^14.2.0",
"eslint-config-aqua": "^4.4.1",
"eslint-import-resolver-nuxt": "^1.0.1",
"eslint-plugin-vue": "^5.2.1",
"node-sass": "^4.11.0",
"nodemon": "^1.19.3",
"postcss-css-variables": "^0.11.0",
"postcss-nested": "^3.0.0",
"prettier": "adamjlev/prettier",
"sass-loader": "^7.1.0"
},
"eslintConfig": {
"extends": [
"aqua/vue",
"aqua/node"
"airbnb-base",
"plugin:eslint-plugin-vue/recommended"
],
"parserOptions": {
"parser": "babel-eslint",
"sourceType": "module"
},
"rules": {
"max-len": [
"error",
{
"code": 120,
"ignoreUrls": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true
}
],
"class-methods-use-this": "off",
"no-param-reassign": "off",
"no-plusplus": [
"error",
{
"allowForLoopAfterthoughts": true
}
],
"no-underscore-dangle": [
"error",
{
"allow": [
"_id"
]
}
],
"import/extensions": [
"error",
"always",
{
"js": "never",
"ts": "never"
}
],
"vue/attribute-hyphenation": 0,
"quote-props": 0
"vue/html-closing-bracket-newline": [
"error",
{
"singleline": "never",
"multiline": "never"
}
],
"vue/max-attributes-per-line": [
"error",
{
"singleline": 5,
"multiline": {
"max": 1,
"allowFirstLine": false
}
}
],
"quote-props": 0,
"indent": [
"error",
"tab"
],
"no-tabs": "off",
"vue/html-indent": [
"error",
"tab"
],
"import/no-extraneous-dependencies": "off",
"no-restricted-syntax": "off",
"no-continue": "off",
"no-await-in-loop": "off",
"comma-dangle": [
"error",
"never"
]
},
"settings": {
"import/resolver": {
"nuxt": {
"nuxtSrcDir": "./src/site",
"extensions": [
".js",
".vue"
]
}
}
}
},
"keywords": [

View File

@ -1,5 +1,5 @@
{
"apps" : [
"apps": [
{
"name": "lolisafe",
"script": "npm",
@ -7,7 +7,7 @@
"env": {
"NODE_ENV": "production"
},
"env_production" : {
"env_production": {
"NODE_ENV": "production"
}
}

View File

@ -1,40 +1,44 @@
exports.up = async knex => {
await knex.schema.createTable('users', table => {
exports.up = async (knex) => {
await knex.schema.createTable('users', (table) => {
table.increments();
table.string('username');
table.string('username').unique();
table.text('password');
table.boolean('enabled');
table.boolean('isAdmin');
table.string('apiKey');
table.string('apiKey').unique();
table.timestamp('passwordEditedAt');
table.timestamp('apiKeyEditedAt');
table.timestamp('createdAt');
table.timestamp('editedAt');
});
await knex.schema.createTable('albums', table => {
await knex.schema.createTable('albums', (table) => {
table.increments();
table.integer('userId');
table.string('name');
table.boolean('nsfw').defaultTo(false);
table.timestamp('zippedAt');
table.timestamp('createdAt');
table.timestamp('editedAt');
table.unique(['userId', 'name']);
});
await knex.schema.createTable('files', table => {
await knex.schema.createTable('files', (table) => {
table.increments();
table.integer('userId');
table.string('name');
table.string('original');
table.string('type');
table.integer('size');
table.boolean('nsfw').defaultTo(false);
table.string('hash');
table.string('ip');
table.timestamp('createdAt');
table.timestamp('editedAt');
});
await knex.schema.createTable('links', table => {
await knex.schema.createTable('links', (table) => {
table.increments();
table.integer('userId');
table.integer('albumId');
@ -45,42 +49,50 @@ exports.up = async knex => {
table.timestamp('expiresAt');
table.timestamp('createdAt');
table.timestamp('editedAt');
table.unique(['userId', 'albumId', 'identifier']);
});
await knex.schema.createTable('albumsFiles', table => {
await knex.schema.createTable('albumsFiles', (table) => {
table.increments();
table.integer('albumId');
table.integer('fileId');
table.unique(['albumId', 'fileId']);
});
await knex.schema.createTable('albumsLinks', table => {
await knex.schema.createTable('albumsLinks', (table) => {
table.increments();
table.integer('albumId');
table.integer('linkId');
table.integer('linkId').unique();
});
await knex.schema.createTable('tags', table => {
await knex.schema.createTable('tags', (table) => {
table.increments();
table.string('uuid');
table.integer('userId');
table.string('name');
table.timestamp('createdAt');
table.timestamp('editedAt');
table.unique(['userId', 'name']);
});
await knex.schema.createTable('fileTags', table => {
await knex.schema.createTable('fileTags', (table) => {
table.increments();
table.integer('fileId');
table.integer('tagId');
table.unique(['fileId', 'tagId']);
});
await knex.schema.createTable('bans', table => {
await knex.schema.createTable('bans', (table) => {
table.increments();
table.string('ip');
table.timestamp('createdAt');
});
};
exports.down = async knex => {
exports.down = async (knex) => {
await knex.schema.dropTableIfExists('users');
await knex.schema.dropTableIfExists('albums');
await knex.schema.dropTableIfExists('files');

View File

@ -1,7 +1,8 @@
/* eslint-disable no-console */
const bcrypt = require('bcrypt');
const moment = require('moment');
exports.seed = async db => {
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;

View File

@ -1,17 +1,60 @@
/* eslint-disable eqeqeq */
/* eslint-disable no-await-in-loop */
/* eslint-disable no-console */
const nodePath = require('path');
const moment = require('moment');
const jetpack = require('fs-jetpack');
const { path } = require('fs-jetpack');
const sharp = require('sharp');
const ffmpeg = require('fluent-ffmpeg');
const imageExtensions = ['.jpg', '.jpeg', '.bmp', '.gif', '.png', '.webp'];
const videoExtensions = ['.webm', '.mp4', '.wmv', '.avi', '.mov'];
const generateThumbnailForImage = async (filename, output) => {
try {
const file = await jetpack.readAsync(nodePath.join(__dirname, '../../uploads', filename), 'buffer');
await sharp(file)
.resize(64, 64)
.toFormat('webp')
.toFile(nodePath.join(__dirname, '../../uploads/thumbs/square', output));
await sharp(file)
.resize(225, null)
.toFormat('webp')
.toFile(nodePath.join(__dirname, '../../uploads/thumbs', output));
console.log('finished', filename);
} catch (error) {
console.log('error', filename);
}
};
const generateThumbnailForVideo = (filename) => {
try {
ffmpeg(nodePath.join(__dirname, '../../uploads', filename))
.thumbnail({
timestamps: [0],
filename: '%b.png',
folder: nodePath.join(__dirname, '../../uploads/thumbs/square'),
size: '64x64'
})
.on('error', (error) => console.error(error.message));
ffmpeg(nodePath.join(__dirname, '../../uploads', filename))
.thumbnail({
timestamps: [0],
filename: '%b.png',
folder: nodePath.join(__dirname, '../../uploads/thumbs'),
size: '150x?'
})
.on('error', (error) => console.error(error.message));
console.log('finished', filename);
} catch (error) {
console.log('error', filename);
}
};
const oldDb = require('knex')({
client: 'sqlite3',
connection: {
filename: nodePath.join(__dirname, '..', '..', 'db')
filename: nodePath.join(__dirname, '../../', 'db')
},
useNullAsDefault: true
});
@ -19,17 +62,17 @@ const oldDb = require('knex')({
const newDb = require('knex')({
client: 'sqlite3',
connection: {
filename: nodePath.join(__dirname, '..', '..', 'database.sqlite')
filename: nodePath.join(__dirname, '../../', 'database.sqlite')
},
postProcessResponse: result => {
postProcessResponse: (result) => {
const booleanFields = [
'enabled',
'enableDownload',
'isAdmin'
];
const processResponse = row => {
Object.keys(row).forEach(key => {
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;
@ -38,7 +81,7 @@ const newDb = require('knex')({
return row;
};
if (Array.isArray(result)) return result.map(row => processResponse(row));
if (Array.isArray(result)) return result.map((row) => processResponse(row));
if (typeof result === 'object') return processResponse(result);
return result;
},
@ -49,8 +92,8 @@ 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'));
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');
@ -60,7 +103,7 @@ const start = async () => {
id: user.id,
username: user.username,
password: user.password,
enabled: user.enabled == 1 ? true : false,
enabled: user.enabled == 1,
isAdmin: false,
apiKey: user.token,
passwordEditedAt: now,
@ -126,7 +169,7 @@ const start = async () => {
});
const filename = file.name;
if (!jetpack.exists(nodePath.join(__dirname, '..', '..', 'uploads', filename))) continue;
if (!jetpack.exists(nodePath.join(__dirname, '../../uploads', filename))) continue;
const ext = nodePath.extname(filename).toLowerCase();
const output = `${filename.slice(0, -ext.length)}.webp`;
if (imageExtensions.includes(ext)) await generateThumbnailForImage(filename, output);
@ -140,45 +183,4 @@ const start = async () => {
process.exit(0);
};
const generateThumbnailForImage = async (filename, output) => {
try {
const file = await jetpack.readAsync(nodePath.join(__dirname, '..', '..', 'uploads', filename), 'buffer');
await sharp(file)
.resize(64, 64)
.toFormat('webp')
.toFile(nodePath.join(__dirname, '..', '..', 'uploads', 'thumbs', 'square', output));
await sharp(file)
.resize(225, null)
.toFormat('webp')
.toFile(nodePath.join(__dirname, '..', '..', 'uploads', 'thumbs', output));
console.log('finished', filename);
} catch (error) {
console.log('error', filename);
}
};
const generateThumbnailForVideo = filename => {
try {
ffmpeg(nodePath.join(__dirname, '..', '..', 'uploads', filename))
.thumbnail({
timestamps: [0],
filename: '%b.png',
folder: nodePath.join(__dirname, '..', '..', 'uploads', 'thumbs', 'square'),
size: '64x64'
})
.on('error', error => console.error(error.message));
ffmpeg(nodePath.join(__dirname, '..', '..', 'uploads', filename))
.thumbnail({
timestamps: [0],
filename: '%b.png',
folder: nodePath.join(__dirname, '..', '..', 'uploads', 'thumbs'),
size: '150x?'
})
.on('error', error => console.error(error.message));
console.log('finished', filename);
} catch (error) {
console.log('error', filename);
}
};
start();

17
src/api/generateThumbs.js Normal file
View File

@ -0,0 +1,17 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const ThumbUtil = require('./utils/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();

View File

@ -3,7 +3,7 @@ const Util = require('../../utils/Util');
class filesGET extends Route {
constructor() {
super('/file/:id', 'get', { adminOnly: true });
super('/admin/file/:id', 'get', { adminOnly: true });
}
async run(req, res, db) {
@ -11,7 +11,10 @@ class filesGET extends Route {
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').where({ id: file.userId }).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

View File

@ -11,7 +11,10 @@ class usersGET extends Route {
if (!id) return res.status(400).json({ message: 'Invalid user ID supplied' });
try {
const user = await db.table('users').where({ id }).first();
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');

View File

@ -1,5 +1,4 @@
const Route = require('../../structures/Route');
const Util = require('../../utils/Util');
class albumDELETE extends Route {
constructor() {

View File

@ -10,15 +10,38 @@ class albumGET extends Route {
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();
const album = await db
.table('albums')
.where({ id, userId: user.id })
.first();
if (!album) return res.status(404).json({ message: 'Album not found' });
const files = await db.table('albumsFiles')
let count = 0;
let files = db
.table('albumsFiles')
.where({ albumId: id })
.join('files', 'albumsFiles.fileId', 'files.id')
.select('files.id', 'files.name')
.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);
}
@ -26,7 +49,8 @@ class albumGET extends Route {
return res.json({
message: 'Successfully retrieved album',
name: album.name,
files
files,
count
});
}
}

View File

@ -21,10 +21,11 @@ class albumGET extends Route {
const files = await db.table('albumsFiles')
.where({ albumId: link.albumId })
.join('files', 'albumsFiles.fileId', 'files.id')
.select('files.name')
.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);
}

View File

@ -1,5 +1,5 @@
const Route = require('../../structures/Route');
const moment = require('moment');
const Route = require('../../structures/Route');
class albumPOST extends Route {
constructor() {
@ -14,18 +14,25 @@ class albumPOST extends Route {
/*
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 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();
await db.table('albums').insert({
const insertObj = {
name,
userId: user.id,
createdAt: now,
editedAt: now
});
};
return res.json({ message: 'The album was created successfully' });
const dbRes = await db.table('albums').insert(insertObj);
insertObj.id = dbRes.pop();
return res.json({ message: 'The album was created successfully', data: insertObj });
}
}

View File

@ -1,8 +1,8 @@
const path = require('path');
const jetpack = require('fs-jetpack');
const Route = require('../../structures/Route');
const Util = require('../../utils/Util');
const log = require('../../utils/Log');
const path = require('path');
const jetpack = require('fs-jetpack');
class albumGET extends Route {
constructor() {
@ -38,7 +38,7 @@ class albumGET extends Route {
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 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.
@ -64,11 +64,11 @@ class albumGET extends Route {
/*
Get the actual files
*/
const fileIds = fileList.map(el => el.fileId);
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);
const filesToZip = files.map((el) => el.name);
try {
Util.createZip(filesToZip, album);
@ -76,7 +76,7 @@ class albumGET extends Route {
.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 filePath = path.join(__dirname, '../../../../', process.env.UPLOAD_FOLDER, 'zips', `${album.userId}-${album.id}.zip`);
const fileName = `lolisafe-${identifier}.zip`;
return res.download(filePath, fileName);
} catch (error) {

View File

@ -1,3 +1,4 @@
/* eslint-disable max-classes-per-file */
const Route = require('../../structures/Route');
const Util = require('../../utils/Util');
@ -12,30 +13,28 @@ class albumsGET extends Route {
of the album files for displaying on the dashboard. It's probably useless
for anyone consuming the API outside of the lolisafe frontend.
*/
const albums = await db.table('albums')
const albums = await db
.table('albums')
.where('albums.userId', user.id)
.select('id', 'name', 'editedAt');
.select('id', 'name', 'createdAt', 'editedAt')
.orderBy('createdAt', 'desc');
for (const album of albums) {
// TODO: Optimize the shit out of this. Ideally a JOIN that grabs all the needed stuff in 1 query instead of 3
// Fetch the total amount of files each album has.
const fileCount = await db.table('albumsFiles') // eslint-disable-line no-await-in-loop
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 filesToFetch = await db.table('albumsFiles') // eslint-disable-line no-await-in-loop
const files = await db // eslint-disable-line no-await-in-loop
.table('albumsFiles')
.join('files', { 'files.id': 'albumsFiles.fileId' })
.where('albumId', album.id)
.select('fileId')
.orderBy('id', 'desc')
.select('files.id', 'files.name')
.orderBy('albumsFiles.id', 'desc')
.limit(5);
// Fetch the actual files
const files = await db.table('files') // eslint-disable-line no-await-in-loop
.whereIn('id', filesToFetch.map(el => el.fileId))
.select('id', 'name');
// Fetch thumbnails and stuff
for (let file of files) {
file = Util.constructFilePublicLink(file);
@ -58,7 +57,8 @@ class albumsDropdownGET extends Route {
}
async run(req, res, db, user) {
const albums = await db.table('albums')
const albums = await db
.table('albums')
.where('userId', user.id)
.select('id', 'name');
return res.json({

View File

@ -1,5 +1,4 @@
const Route = require('../../../structures/Route');
const { dump } = require('dumper.js');
class linkDELETE extends Route {
constructor() {

View File

@ -1,5 +1,4 @@
const Route = require('../../../structures/Route');
const log = require('../../../utils/Log');
class linkEditPOST extends Route {
constructor() {
@ -14,17 +13,22 @@ class linkEditPOST extends Route {
/*
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' });
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 {
await db.table('links')
const updateObj = {
enableDownload: enableDownload || false,
expiresAt // This one should be null if not supplied
};
await db
.table('links')
.where({ identifier })
.update({
enableDownload: enableDownload || false,
expiresAt // This one should be null if not supplied
});
return res.json({ message: 'Editing the link was successful' });
.update(updateObj);
return res.json({ message: 'Editing the link was successful', data: updateObj });
} catch (error) {
return super.error(res, error);
}

View File

@ -14,23 +14,47 @@ class linkPOST extends Route {
/*
Make sure the album exists
*/
const exists = await db.table('albums').where({ id: albumId, userId: user.id }).first();
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' });
if (count[0].count >= parseInt(process.env.MAX_LINKS_PER_ALBUM, 10)) return res.status(400).json({ message: 'Maximum links per album 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' });
/*
Try to allocate a new identifier on the db
*/
const identifier = await Util.getUniqueAlbumIdentifier();
if (!identifier) return res.status(500).json({ message: 'There was a problem allocating a link for your album' });
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 {
await db.table('links').insert({
const insertObj = {
identifier,
userId: user.id,
albumId,
@ -38,11 +62,12 @@ class linkPOST extends Route {
enableDownload: true,
expiresAt: null,
views: 0
});
};
await db.table('links').insert(insertObj);
return res.json({
message: 'The link was created successfully',
identifier
data: insertObj
});
} catch (error) {
return super.error(res, error);

View File

@ -1,7 +1,7 @@
const Route = require('../../structures/Route');
const bcrypt = require('bcrypt');
const moment = require('moment');
const JWT = require('jsonwebtoken');
const Route = require('../../structures/Route');
class loginPOST extends Route {
constructor() {

View File

@ -1,7 +1,7 @@
const Route = require('../../structures/Route');
const log = require('../../utils/Log');
const bcrypt = require('bcrypt');
const moment = require('moment');
const Route = require('../../structures/Route');
const log = require('../../utils/Log');
class registerPOST extends Route {
constructor() {
@ -9,7 +9,7 @@ class registerPOST extends Route {
}
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 (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' });

View File

@ -24,7 +24,8 @@ class albumAddPOST extends Route {
}
return res.json({
message: 'Successfully added file to album'
message: 'Successfully added file to album',
data: { fileId, album: { id: album.id, name: album.name } }
});
}
}

View File

@ -25,7 +25,8 @@ class albumDelPOST extends Route {
}
return res.json({
message: 'Successfully removed file from album'
message: 'Successfully removed file from album',
data: { fileId, album: { id: album.id, name: album.name } }
});
}
}

View File

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

View File

@ -18,7 +18,7 @@ class filesGET extends Route {
.select('albumId');
if (albumFiles.length) {
albumFiles = albumFiles.map(a => a.albumId);
albumFiles = albumFiles.map((a) => a.albumId);
albums = await db.table('albums')
.whereIn('id', albumFiles)
.select('id', 'name');

View File

@ -7,10 +7,26 @@ class filesGET extends Route {
}
async run(req, res, db, user) {
// Get all the files from the user
const files = await db.table('files')
.where('userId', user.id)
.orderBy('id', 'desc');
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) {
@ -19,7 +35,8 @@ class filesGET extends Route {
return res.json({
message: 'Successfully retrieved files',
files
files,
count
});
}
}

View File

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

View File

@ -7,24 +7,29 @@ class tagAddPOST extends Route {
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' });
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.' });
tagNames.forEach(async tag => {
try {
await db.table('fileTags').insert({ fileId, tag });
} catch (error) {
return super.error(res, error);
}
});
// 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 file to album'
message: 'Successfully added tag to file',
data: { fileId, tag }
});
// eslint-disable-next-line consistent-return
}
}

View File

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

View File

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

View File

@ -15,10 +15,10 @@ class configGET extends Route {
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' ? true : false,
generateZips: process.env.GENERATE_ZIPS == 'true' ? true : false,
publicMode: process.env.PUBLIC_MODE == 'true' ? true : false,
enableAccounts: process.env.USER_ACCOUNTS == 'true' ? true : false
generateThumbnails: process.env.GENERATE_THUMBNAILS === 'true',
generateZips: process.env.GENERATE_ZIPS === 'true',
publicMode: process.env.PUBLIC_MODE === 'true',
enableAccounts: process.env.USER_ACCOUNTS === 'true'
}
});
}

View File

@ -27,7 +27,7 @@ class tagDELETE extends Route {
Delete the tag
*/
await db.table('tags').where({ id }).delete();
return res.json({ message: 'The tag was deleted successfully' });
return res.json({ message: 'The tag was deleted successfully', data: tag });
} catch (error) {
return super.error(res, error);
}

View File

@ -1,5 +1,5 @@
const Route = require('../../structures/Route');
const moment = require('moment');
const Route = require('../../structures/Route');
class tagPOST extends Route {
constructor() {
@ -18,14 +18,18 @@ class tagPOST extends Route {
if (tag) return res.status(401).json({ message: 'There\'s already a tag with that name' });
const now = moment.utc().toDate();
await db.table('tags').insert({
const insertObj = {
name,
userId: user.id,
createdAt: now,
editedAt: now
});
};
return res.json({ message: 'The tag was created successfully' });
const dbRes = await db.table('tags').insert(insertObj);
insertObj.id = dbRes.pop();
return res.json({ message: 'The tag was created successfully', data: insertObj });
}
}

View File

@ -1,5 +1,4 @@
const Route = require('../../structures/Route');
const Util = require('../../utils/Util');
class tagsGET extends Route {
constructor() {

View File

@ -1,8 +1,8 @@
const Route = require('../../structures/Route');
const path = require('path');
const Util = require('../../utils/Util');
const jetpack = require('fs-jetpack');
const randomstring = require('randomstring');
const Util = require('../../utils/Util');
const Route = require('../../structures/Route');
class uploadPOST extends Route {
constructor() {
@ -12,7 +12,7 @@ class uploadPOST extends Route {
});
}
async run(req, res, db) {
async run(req, res) {
const filename = Util.getUniqueFilename(randomstring.generate(32));
// console.log('Files', req.body.files);
const info = {
@ -21,24 +21,18 @@ class uploadPOST extends Route {
};
for (const chunk of req.body.files) {
const { uuid, count } = chunk;
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();
@ -49,10 +43,7 @@ class uploadPOST extends Route {
for (let i = 0; i < chunkDir.length; i++) {
const dir = path.join(__dirname,
'..',
'..',
'..',
'..',
'../../../../',
process.env.UPLOAD_FOLDER,
'chunks',
uuid,

View File

@ -1,17 +1,18 @@
const Route = require('../../structures/Route');
const path = require('path');
const Util = require('../../utils/Util');
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
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.`));
@ -19,22 +20,21 @@ const upload = multer({
return cb(new Error(`${path.extname(file.originalname).toLowerCase()} is a blacklisted extension.`));
}
*/
return cb(null, true);
}
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: If source is a video, generate a thumb of the first frame and save the video length to the file?
Another possible solution would be to play a gif on hover that grabs a few chunks like youtube.
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
- Addendum to this: 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.
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 {
@ -47,7 +47,7 @@ class uploadPOST extends Route {
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' });
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' });
@ -56,12 +56,13 @@ class uploadPOST extends Route {
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 => {
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];
@ -83,10 +84,7 @@ class uploadPOST extends Route {
if (remappedKeys && remappedKeys.uuid) {
const chunkOutput = path.join(__dirname,
'..',
'..',
'..',
'..',
'../../../../',
process.env.UPLOAD_FOLDER,
'chunks',
remappedKeys.uuid,
@ -94,10 +92,7 @@ class uploadPOST extends Route {
await jetpack.writeAsync(chunkOutput, file.buffer);
} else {
const output = path.join(__dirname,
'..',
'..',
'..',
'..',
'../../../../',
process.env.UPLOAD_FOLDER,
filename);
await jetpack.writeAsync(output, file.buffer);
@ -147,7 +142,7 @@ class uploadPOST extends Route {
async checkIfFileExists(db, user, hash) {
const exists = await db.table('files')
.where(function() { // eslint-disable-line func-names
.where(function () { // eslint-disable-line func-names
if (user) this.where('userId', user.id);
else this.whereNull('userId');
})
@ -222,6 +217,7 @@ class uploadPOST extends Route {
}
return body;
}
return keys;
}
}

View File

@ -1,7 +1,7 @@
const Route = require('../../structures/Route');
const randomstring = require('randomstring');
const moment = require('moment');
const { dump } = require('dumper.js');
const Route = require('../../structures/Route');
class apiKeyPOST extends Route {
constructor() {

View File

@ -1,7 +1,7 @@
const Route = require('../../structures/Route');
const log = require('../../utils/Log');
const bcrypt = require('bcrypt');
const moment = require('moment');
const Route = require('../../structures/Route');
const log = require('../../utils/Log');
class changePasswordPOST extends Route {
constructor() {

View File

@ -11,7 +11,8 @@ class usersGET extends Route {
user: {
id: user.id,
username: user.username,
isAdmin: user.isAdmin
isAdmin: user.isAdmin,
apiKey: user.apiKey
}
});
}

View File

@ -7,23 +7,19 @@ const db = require('knex')({
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
filename: nodePath.join(__dirname, '..', '..', '..', 'database.sqlite')
filename: nodePath.join(__dirname, '../../../database.sqlite')
},
postProcessResponse: result => {
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'
];
const booleanFields = ['enabled', 'enableDownload', 'isAdmin'];
const processResponse = row => {
Object.keys(row).forEach(key => {
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;
@ -32,11 +28,11 @@ const db = require('knex')({
return row;
};
if (Array.isArray(result)) return result.map(row => processResponse(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' ? true : false
useNullAsDefault: process.env.DB_CLIENT === 'sqlite3'
});
const moment = require('moment');
const log = require('../utils/Log');
@ -52,11 +48,15 @@ class Route {
}
async authorize(req, res) {
const banned = await db.table('bans').where({ ip: req.ip }).first();
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
// 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' });
@ -72,11 +72,16 @@ class Route {
const id = decoded ? decoded.sub : '';
const iat = decoded ? decoded.iat : '';
const user = await db.table('users').where({ id }).first();
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 (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' });
if (this.options.adminOnly && !user.isAdmin) { return res.status(401).json({ message: 'Invalid authorization' }); }
return this.run(req, res, db, user);
});
@ -84,16 +89,17 @@ class Route {
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();
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(req, res, db) { // eslint-disable-line no-unused-vars
return;
}
run() {}
error(res, error) {
log.error(error);

View File

@ -1,6 +1,5 @@
require('dotenv').config();
const log = require('../utils/Log');
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
@ -8,7 +7,11 @@ 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 log = require('../utils/Log');
const ThumbUtil = require('../utils/ThumbUtil');
// 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),
@ -32,22 +35,48 @@ class Server {
});
this.server.use(bodyParser.urlencoded({ extended: true }));
this.server.use(bodyParser.json());
if (process.env.NODE_ENV !== 'production') {
this.server.use(morgan('combined', {
skip(req) {
let ext = req.path.split('.').pop();
if (ext) { ext = `.${ext.toLowerCase()}`; }
if (
ThumbUtil.imageExtensions.indexOf(ext) > -1
|| ThumbUtil.videoExtensions.indexOf(ext) > -1
|| req.path.indexOf('_nuxt') > -1
|| req.path.indexOf('favicon.ico') > -1
) {
return true;
}
return false;
},
'stream': {
write(str) { log.debug(str); }
}
}));
}
// this.server.use(rateLimiter);
// Serve the uploads
this.server.use(express.static(path.join(__dirname, '..', '..', '..', 'uploads')));
this.routesFolder = path.join(__dirname, '..', 'routes');
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));
jetpack.find(this.routesFolder, { matching: '*.js' }).forEach((routeFile) => {
// eslint-disable-next-line import/no-dynamic-require, global-require
const RouteClass = require(path.join('../../../', routeFile));
let routes = [RouteClass];
if (Array.isArray(RouteClass)) routes = RouteClass;
for (const File of routes) {
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}`);
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}`);
}
}
});
}
@ -55,7 +84,7 @@ class Server {
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')));
this.server.use(express.static(path.join(__dirname, '../../../dist')));
}
/*
@ -66,7 +95,7 @@ class Server {
*/
this.server.all('*', (_req, res) => {
try {
res.sendFile(path.join(__dirname, '..', '..', '..', 'dist', 'index.html'));
res.sendFile(path.join(__dirname, '../../../dist/index.html'));
} catch (error) {
res.json({ success: false, message: 'Something went wrong' });
}

View File

@ -3,30 +3,29 @@ const { dump } = require('dumper.js');
class Log {
static info(args) {
if (this.checkIfArrayOrObject(args)) dump(args);
if (Log.checkIfArrayOrObject(args)) dump(args);
else console.log(args); // eslint-disable-line no-console
}
static success(args) {
if (this.checkIfArrayOrObject(args)) dump(args);
if (Log.checkIfArrayOrObject(args)) dump(args);
else console.log(chalk.green(args)); // eslint-disable-line no-console
}
static warn(args) {
if (this.checkIfArrayOrObject(args)) dump(args);
if (Log.checkIfArrayOrObject(args)) dump(args);
else console.log(chalk.yellow(args)); // eslint-disable-line no-console
}
static error(args) {
if (this.checkIfArrayOrObject(args)) dump(args);
if (Log.checkIfArrayOrObject(args)) dump(args);
else console.log(chalk.red(args)); // eslint-disable-line no-console
}
/*
static dump(args) {
dump(args);
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;

View File

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

108
src/api/utils/ThumbUtil.js Normal file
View File

@ -0,0 +1,108 @@
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)}.png`;
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('png')
.toFile(path.join(ThumbUtil.squareThumbPath, output));
await sharp(file)
.resize(225, null)
.toFormat('png')
.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.png',
folder: ThumbUtil.squareThumbPath,
size: '64x64'
})
.on('error', (error) => log.error(error.message));
ffmpeg(filePath)
.thumbnail({
timestamps: [0],
filename: '%b.png',
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),
log: log.debug
});
} 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)}.png`,
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;

View File

@ -1,3 +1,4 @@
/* eslint-disable no-await-in-loop */
const jetpack = require('fs-jetpack');
const randomstring = require('randomstring');
const path = require('path');
@ -9,23 +10,23 @@ const db = require('knex')({
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
filename: path.join(__dirname, '..', '..', '..', 'database.sqlite')
filename: path.join(__dirname, '../../../database.sqlite')
},
useNullAsDefault: process.env.DB_CLIENT === 'sqlite' ? true : false
useNullAsDefault: process.env.DB_CLIENT === 'sqlite'
});
const moment = require('moment');
const log = require('../utils/Log');
const crypto = require('crypto');
const sharp = require('sharp');
const ffmpeg = require('fluent-ffmpeg');
const Zip = require('adm-zip');
const uuidv4 = require('uuid/v4');
const imageExtensions = ['.jpg', '.jpeg', '.bmp', '.gif', '.png', '.webp'];
const videoExtensions = ['.webm', '.mp4', '.wmv', '.avi', '.mov'];
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();
}
@ -34,63 +35,17 @@ class Util {
return blockedExtensions.includes(extension);
}
static generateThumbnails(filename) {
const ext = path.extname(filename).toLowerCase();
const output = `${filename.slice(0, -ext.length)}.webp`;
if (imageExtensions.includes(ext)) return this.generateThumbnailForImage(filename, output);
if (videoExtensions.includes(ext)) return this.generateThumbnailForVideo(filename);
return null;
}
static async generateThumbnailForImage(filename, output) {
const file = await jetpack.readAsync(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, filename), 'buffer');
await sharp(file)
.resize(64, 64)
.toFormat('webp')
.toFile(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'thumbs', 'square', output));
await sharp(file)
.resize(225, null)
.toFormat('webp')
.toFile(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'thumbs', output));
}
static generateThumbnailForVideo(filename) {
ffmpeg(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, filename))
.thumbnail({
timestamps: [0],
filename: '%b.png',
folder: path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'thumbs', 'square'),
size: '64x64'
})
.on('error', error => log.error(error.message));
ffmpeg(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, filename))
.thumbnail({
timestamps: [0],
filename: '%b.png',
folder: path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'thumbs'),
size: '150x?'
})
.on('error', error => log.error(error.message));
}
static getFileThumbnail(filename) {
if (!filename) return null;
const ext = path.extname(filename).toLowerCase();
const extension = imageExtensions.includes(ext) ? 'webp' : videoExtensions.includes(ext) ? 'png' : null;
if (!extension) return null;
return `${filename.slice(0, -ext.length)}.${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 = this.getFileThumbnail(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;
}
@ -103,7 +58,7 @@ class Util {
}) + 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(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, filename));
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');
@ -118,7 +73,10 @@ class Util {
length: parseInt(process.env.GENERATED_ALBUM_LENGTH, 10),
capitalization: 'lowercase'
});
const exists = await db.table('links').where({ identifier }).first();
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
@ -131,7 +89,7 @@ class Util {
}
static async getFileHash(filename) {
const file = await jetpack.readAsync(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, filename), 'buffer');
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;
@ -143,7 +101,10 @@ class Util {
}
static generateFileHash(data) {
const hash = crypto.createHash('md5').update(data).digest('hex');
const hash = crypto
.createHash('md5')
.update(data)
.digest('hex');
return hash;
}
@ -152,18 +113,16 @@ class Util {
}
static async deleteFile(filename, deleteFromDB = false) {
const thumbName = this.getFileThumbnail(filename);
const thumbName = ThumbUtil.getFileThumbnail(filename);
try {
await jetpack.removeAsync(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, filename));
if (thumbName) {
const thumb = path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'thumbs', thumbName);
const thumbSquare = path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'thumbs', 'square', thumbName);
if (await jetpack.existsAsync(thumb)) jetpack.removeAsync(thumb);
if (await jetpack.existsAsync(thumbSquare)) jetpack.removeAsync(thumbSquare);
}
await jetpack.removeAsync(path.join(Util.uploadPath, filename));
await ThumbUtil.removeThumbs(thumbName);
if (deleteFromDB) {
await db.table('files').where('name', filename).delete();
await db
.table('files')
.where('name', filename)
.delete();
}
} catch (error) {
log.error(`There was an error removing the file < ${filename} >`);
@ -175,10 +134,13 @@ class Util {
try {
const fileAlbums = await db.table('albumsFiles').where({ albumId: id });
for (const fileAlbum of fileAlbums) {
const file = await db.table('files')
const file = await db
.table('files')
.where({ id: fileAlbum.fileId })
.first();
if (!file) continue;
await this.deleteFile(file.name, true);
}
} catch (error) {
@ -201,7 +163,8 @@ class Util {
try {
const fileTags = await db.table('fileTags').where({ tagId: id });
for (const fileTag of fileTags) {
const file = await db.table('files')
const file = await db
.table('files')
.where({ id: fileTag.fileId })
.first();
if (!file) continue;
@ -231,7 +194,10 @@ class Util {
const id = decoded ? decoded.sub : '';
const iat = decoded ? decoded.iat : '';
const user = await db.table('users').where({ id }).first();
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;
@ -243,13 +209,23 @@ class Util {
try {
const zip = new Zip();
for (const file of files) {
zip.addLocalFile(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, file));
zip.addLocalFile(path.join(Util.uploadPath, file));
}
zip.writeZip(path.join(__dirname, '..', '..', '..', process.env.UPLOAD_FOLDER, 'zips', `${album.userId}-${album.id}.zip`));
zip.writeZip(
path.join(
__dirname,
'../../../',
process.env.UPLOAD_FOLDER,
'zips',
`${album.userId}-${album.id}.zip`
)
);
} catch (error) {
log.error(error);
}
}
static generateThumbnails = ThumbUtil.generateThumbnails;
}
module.exports = Util;

View File

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

View File

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

View File

@ -1,6 +1,8 @@
/* eslint-disable no-console */
const randomstring = require('randomstring');
const jetpack = require('fs-jetpack');
const qoa = require('qoa');
qoa.config({
prefix: '>',
underlineQuery: false
@ -122,6 +124,7 @@ async function start() {
const allSettings = Object.assign(defaultSettings, response);
const keys = Object.keys(allSettings);
// eslint-disable-next-line no-restricted-syntax
for (const item of keys) {
envfile += `${item}=${allSettings[item]}\n`;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

View File

@ -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
$lolisafe: #323846;
$lolisafe-invert: findColorInvert($lolisafe);
// XXX: EXPERIMENTAL, CHECK IF WE NEED ORIGINAL PRIMARY ANYWHERE
// $primary: $lolisafe;
// $primary-invert: $lolisafe-invert;
// declare custom colors
$custom-colors: (
"lolisafe":($lolisafe, $lolisafe-invert)
);

View File

@ -1,25 +1,83 @@
// $basePink: #EC1A55;
$base-1: #292e39;
$base-2: #2E3440;
$base-3: #3B4252;
$base-4: #434C5E;
$base-5: #4C566A;
$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;
$baseBlue: #30a9ed;
$baseBlueHover: rgb(21, 135, 201);
$uploaderDropdownColor: #797979;
$boxShadow: 0 10px 15px rgba(4,39,107,0.2);
$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;

View File

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

View File

@ -1,15 +1,24 @@
// Let's first take care of having the customized colors ready.
@import "./_colors.scss";
@import './_bulma_colors_extender.scss';
@import './_colors.scss';
// Bulma/Buefy customization
@import "../../../node_modules/bulma/sass/utilities/_all.sass";
@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;
$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 '../../../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 {
// font-size: 100%;
@ -18,9 +27,9 @@ html {
}
a {
color: #5E81AC;
color: #5e81ac;
&:hover {
color: #81A1C1;
color: #81a1c1;
text-decoration: underline;
}
}
@ -43,10 +52,18 @@ h4 {
}
@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; }
.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 {
@ -58,8 +75,12 @@ hr {
height: 1px;
}
// Bulma color changes.
.tooltip.is-top.is-primary:before { border-top: 5px solid #20222b; }
.tooltip.is-primary:after { background: #20222b; }
.tooltip.is-top.is-primary:before {
border-top: 5px solid #20222b;
}
.tooltip.is-primary:after {
background: #20222b;
}
div#drag-overlay {
position: fixed;
@ -93,7 +114,6 @@ div#drag-overlay {
}
}
section.hero {
&.dashboard {
// background-color: $backgroundLight1 !important;
@ -103,10 +123,12 @@ section.hero {
}
}
section input, section a.button {
section input,
section a.button {
font-size: 14px !important;
}
section input, section p.control a.button {
section input,
section p.control a.button {
border-left: 0px !important;
border-top: 0px !important;
border-right: 0px !important;
@ -114,13 +136,15 @@ section input, section p.control a.button {
box-shadow: 0 0 0 !important;
}
section p.control a.button { margin-left: 10px !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 {
.switch input[type='checkbox'] + .check:before {
background: #fbfbfb;
}
@ -128,7 +152,8 @@ section p.control button {
Register and Login forms
*/
section.hero.is-login, section.hero.is-register {
section.hero.is-login,
section.hero.is-register {
a {
font-size: 1.25em;
line-height: 2.5em;
@ -174,45 +199,31 @@ 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;
}
.input, .taginput .taginput-container.is-focusable, .textarea, .select select {
border: 2px solid #21252d;
border-radius: .3em !important;
background: rgba(0, 0, 0, 0.15);
padding: 1rem;
color: $textColor;
height: 3rem;
&:focus,
&:hover {
border: 2px solid #21252d;
}
&::placeholder {
color: $textColor;
}
}
button.button.is-primary {
/* 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;
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;
@ -224,13 +235,16 @@ svg.waves {
user-select: none;
overflow: hidden;
}
div.field-body > div.field { text-align: left; }
div.field-body > div.field {
text-align: left;
}
table.table {
background: $base-2;
color: $textColor;
border: 0;
thead {
th, td {
th,
td {
color: $textColor;
}
}
@ -244,7 +258,148 @@ table.table {
}
}
}
th, td {
th,
td {
border-color: #ffffff1c;
}
}
.lolisafe-input input,
.lolisafe-select select,
.lolisafe-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;
}
}
.lolisafe-input .icon {
color: #323846 !important;
}
// vue-bar
.vb > .vb-dragger {
z-index: 5;
width: 12px;
right: 0;
}
.vb > .vb-dragger > .vb-dragger-styler {
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
-webkit-transform: rotate3d(0, 0, 0, 0);
transform: rotate3d(0, 0, 0, 0);
-webkit-transition: background-color 100ms ease-out, margin 100ms ease-out, height 100ms ease-out;
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;
}
.lolisafe-on-border.field.is-floating-label .label:before {
background-color: $lolisafe;
}
.is-lolisafe.divider::after, .is-lolisafe.divider::before {
background-color: #21252d;
}
.lolisafe.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;
}

View File

@ -0,0 +1,270 @@
<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">
<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: () => ({})
}
},
data() {
return {
isCreatingLink: false,
isDeletingLinks: []
};
},
computed: mapState(['config', 'auth']),
methods: {
...mapActions({
deleteAlbumAction: 'albums/deleteAlbum',
deleteAlbumLinkAction: 'albums/deleteLink',
updateLinkOptionsAction: 'albums/updateLinkOptions',
createLinkAction: 'albums/createLink',
createCustomLinkAction: 'albums/createCustomLink',
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 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 {
-webkit-box-shadow: $boxShadowLight;
box-shadow: $boxShadowLight;
}
}
}
</style>
<style lang="scss">
@import '~/assets/styles/_colors.scss';
.b-table {
.table-wrapper {
-webkit-box-shadow: $boxShadowLight;
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>

View File

@ -0,0 +1,187 @@
<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)"
:albumId="album.id" />
</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;
-webkit-transition: transform 0.1s linear;
-moz-transition: transform 0.1s linear;
-ms-transition: transform 0.1s linear;
-o-transition: transform 0.1s linear;
transition: transform 0.1s linear;
&.active {
transform: rotate(-45deg);
}
}
}
div.thumb {
width: 64px;
height: 64px;
-webkit-box-shadow: $boxShadowLight;
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>

View File

@ -1,12 +1,18 @@
<template>
<!-- eslint-disable max-len -->
<footer>
<svg viewBox="0 0 1920 250"
<svg
viewBox="0 0 1920 250"
class="waves">
<path d="M1920 250H0V0s126.707 78.536 349.975 80.05c177.852 1.203 362.805-63.874 553.803-63.874 290.517 0 383.458 57.712 603.992 61.408 220.527 3.696 278.059-61.408 412.23-17.239"
<path
d="M1920 250H0V0s126.707 78.536 349.975 80.05c177.852 1.203 362.805-63.874 553.803-63.874 290.517 0 383.458 57.712 603.992 61.408 220.527 3.696 278.059-61.408 412.23-17.239"
class="wave-1" />
<path d="M1920 144s-467.917 116.857-1027.243-17.294C369.986 1.322 0 45.578 0 45.578V250h1920V144z"
<path
d="M1920 144s-467.917 116.857-1027.243-17.294C369.986 1.322 0 45.578 0 45.578V250h1920V144z"
class="wave-2" />
<path d="M0 195.553s208.547-75.581 701.325-20.768c376.707 41.908 520.834-67.962 722.545-67.962 222.926 0 311.553 83.523 496.129 86.394V250H0v-54.447z"
<path
d="M0 195.553s208.547-75.581 701.325-20.768c376.707 41.908 520.834-67.962 722.545-67.962 222.926 0 311.553 83.523 496.129 86.394V250H0v-54.447z"
class="wave-3" />
</svg>
<div>
@ -15,7 +21,8 @@
<div class="column is-narrow">
<h4>lolisafe</h4>
<span>© 2017-2020
<a href="https://github.com/pitu"
<a
href="https://github.com/pitu"
class="no-block">Pitu</a>
</span><br>
<span>v{{ version }}</span>
@ -24,22 +31,33 @@
<div class="columns is-gapless">
<div class="column" />
<div class="column">
<nuxt-link to="/">Home</nuxt-link>
<nuxt-link to="/faq">FAQ</nuxt-link>
<nuxt-link to="/">
Home
</nuxt-link>
<nuxt-link to="/faq">
FAQ
</nuxt-link>
</div>
<div class="column">
<nuxt-link to="/dashboard">Dashboard</nuxt-link>
<nuxt-link to="/dashboard">Files</nuxt-link>
<nuxt-link to="/dashboard/albums">Albums</nuxt-link>
<nuxt-link to="/dashboard/account">Account</nuxt-link>
<nuxt-link to="/dashboard">
Dashboard
</nuxt-link>
<nuxt-link to="/dashboard">
Files
</nuxt-link>
<nuxt-link to="/dashboard/albums">
Albums
</nuxt-link>
<nuxt-link to="/dashboard/account">
Account
</nuxt-link>
</div>
<div class="column">
<a href="https://github.com/weebdev/lolisafe">GitHub</a>
<a href="https://patreon.com/pitu">Patreon</a>
<a href="https://discord.gg/5g6vgwn">Discord</a>
</div>
<div class="column">
<a v-if="loggedIn"
<a
v-if="loggedIn"
@click="createShareXThing">ShareX Config</a>
<a href="https://chrome.google.com/webstore/detail/lolisafe-uploader/enkkmplljfjppcdaancckgilmgoiofnj">Chrome Extension</a>
</div>
@ -50,27 +68,32 @@
</div>
</footer>
</template>
<script>
/* eslint-disable no-restricted-globals */
import { mapState, mapGetters } from 'vuex';
import { saveAs } from 'file-saver';
export default {
computed: {
loggedIn() {
return this.$store.state.loggedIn;
},
version() {
return this.$store.state.config.version;
}
...mapGetters({ loggedIn: 'auth/isLoggedIn' }),
...mapState({
version: (state) => state.config.version,
serviceName: (state) => state.config.serviceName,
token: (state) => state.auth.token
})
},
methods: {
createShareXThing() {
const sharexFile = `{
"Name": "${this.$store.state.config.serviceName}",
"Name": "${this.serviceName}",
"DestinationType": "ImageUploader, FileUploader",
"RequestType": "POST",
"RequestURL": "${location.origin}/api/upload",
"FileFormName": "files[]",
"Headers": {
"authorization": "Bearer ${this.$store.state.token}",
"authorization": "Bearer ${this.token}",
"accept": "application/vnd.lolisafe.json"
},
"ResponseType": "Text",
@ -83,6 +106,7 @@ export default {
}
};
</script>
<style lang="scss" scoped>
@import '~/assets/styles/_colors.scss';
footer {

View File

@ -1,163 +1,135 @@
<template>
<div>
<div v-if="enableToolbar"
class="toolbar">
<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>
<nav class="level">
<div class="level-left">
<div class="level-item">
<slot name="pagination" />
</div>
</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="!showList">
<Waterfall :gutterWidth="10"
:gutterHeight="4">
<!--
TODO: Implement search based on originalName, albumName and tags
<input v-if="enableSearch"
v-model="searchTerm"
type="text"
placeholder="Search..."
@input="search()"
@keyup.enter="search()">
-->
<!-- TODO: Implement pagination -->
<WaterfallItem v-for="(item, index) in gridFiles"
v-if="showWaterfall"
:key="index"
:width="width"
move-class="item-move">
<template v-if="!images.showList">
<Waterfall
:gutterWidth="10"
:gutterHeight="4"
:options="{fitWidth: true}"
:itemWidth="width"
:items="gridFiles">
<template v-slot="{item}">
<template v-if="isPublic">
<a :href="`${item.url}`"
target="_blank">
<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">
<span v-if="!item.thumb && item.name"
class="extension">{{ item.name.split('.').pop() }}</span>
<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">
<span v-if="!item.thumb && item.name"
class="extension">{{ item.name.split('.').pop() }}</span>
<div v-if="!isPublic"
<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">
<b-tooltip label="Link"
position="is-top">
<a :href="`${item.url}`"
target="_blank"
class="btn">
<i class="icon-web-code" />
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="Albums"
position="is-top">
<a class="btn"
@click="openAlbumModal(item)">
<i class="icon-interface-window" />
<b-tooltip label="Edit" position="is-top">
<a class="btn" @click="handleFileModal(item)">
<i class="mdi mdi-pencil" />
</a>
</b-tooltip>
<!--
<b-tooltip label="Tags"
position="is-top">
<a @click="manageTags(item)">
<i class="icon-ecommerce-tag-c" />
<b-tooltip label="Delete" position="is-top">
<a class="btn" @click="deleteFile(item)">
<i class="mdi mdi-delete" />
</a>
</b-tooltip>
-->
<b-tooltip label="Delete"
position="is-top">
<a class="btn"
@click="deleteFile(item, index)">
<i class="icon-editorial-trash-a-l" />
</a>
</b-tooltip>
<b-tooltip v-if="user && user.isAdmin"
label="More info"
position="is-top"
class="more">
<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="icon-interface-more" />
<i class="mdi mdi-dots-horizontal" />
</nuxt-link>
</b-tooltip>
</div>
</template>
</WaterfallItem>
</template>
</Waterfall>
<button
v-if="moreFiles"
class="button is-primary"
@click="loadMoreFiles">Load more</button>
</template>
<div v-else>
<b-table
:data="gridFiles || []"
:mobile-cards="true">
<template slot-scope="props">
<template v-if="!props.row.hideFromList">
<b-table-column field="url"
label="URL">
<a :href="props.row.url"
target="_blank">{{ props.row.url }}</a>
</b-table-column>
<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 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 field="uploaded"
label="Uploaded"
centered>
<span><timeago :since="props.row.createdAt" /></span>
</b-table-column>
<b-table-column field="purge"
centered>
<b-tooltip label="Albums"
position="is-top">
<a class="btn"
@click="openAlbumModal(props.row)">
<i class="icon-interface-window" />
</a>
</b-tooltip>
<b-tooltip label="Delete"
position="is-top"
class="is-danger">
<a class="is-danger"
@click="deleteFile(props.row)">
<i class="icon-editorial-trash-a-l" />
</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="icon-interface-more" />
</nuxt-link>
</b-tooltip>
</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>
</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" />
@ -167,54 +139,39 @@
</div>
</template>
<template slot="footer">
<div class="has-text-right">
{{ files.length }} files
<div class="has-text-right has-text-default">
Showing {{ files.length }} files ({{ total }} total)
</div>
</template>
</b-table>
<button
v-if="moreFiles"
class="button is-primary mt2"
@click="loadMoreFiles">Load more</button>
</div>
<b-modal :active.sync="isAlbumsModalActive"
:width="640"
scroll="keep">
<div class="card albumsModal">
<div class="card-content">
<div class="content">
<h3 class="subtitle">Select the albums this file should be a part of</h3>
<hr>
<div class="albums-container">
<div v-for="(album, index) in albums"
:key="index"
class="album">
<div class="field">
<b-checkbox :value="isAlbumSelected(album.id)"
@input="albumCheckboxClicked($event, album.id)">{{ album.name }}</b-checkbox>
</div>
</div>
</div>
</div>
</div>
</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 WaterfallItem from './waterfall/WaterfallItem.vue';
import ImageInfo from '~/components/image-modal/ImageInfo.vue';
export default {
components: {
Waterfall,
WaterfallItem
ImageInfo
},
props: {
files: {
type: Array,
default: () => []
},
total: {
type: Number,
default: 0
},
fixed: {
type: Boolean,
default: false
@ -238,212 +195,309 @@ export default {
},
data() {
return {
showWaterfall: true,
searchTerm: null,
showList: false,
albums: [],
hoveredItems: [],
isAlbumsModalActive: false,
showingModalForFile: null,
filesOffset: 0,
filesOffsetEnd: 50,
filesPerPage: 50
filesOffsetWaterfall: 0,
filesOffsetEndWaterfall: 50,
filesPerPageWaterfall: 50,
modalData: {
file: null,
tags: null,
albums: null
}
};
},
computed: {
user() {
return this.$store.state.user;
},
...mapState({
user: (state) => state.auth.user,
albums: (state) => state.albums.tinyDetails,
images: (state) => state.images
}),
blank() {
return require('@/assets/images/blank2.jpg');
// eslint-disable-next-line global-require, import/no-unresolved
return require('@/assets/images/blank.png');
},
gridFiles() {
return this.files.slice(this.filesOffset, this.filesOffsetEnd);
},
moreFiles() {
return this.files.length > this.filesOffsetEnd;
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: {
loadMoreFiles() {
this.filesOffsetEnd = this.filesOffsetEnd + this.filesPerPage;
},
async search() {
const data = await this.$search.do(this.searchTerm, [
'name',
'original',
'type',
'albums:name'
]);
console.log('> Search result data', data);
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, index) {
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',
hasIcon: true,
onConfirm: async () => {
const response = await this.$axios.$delete(`file/${file.id}`);
if (this.showList) {
file.hideFromList = true;
this.$forceUpdate();
} else {
this.showWaterfall = false;
this.files.splice(index, 1);
this.$nextTick(() => {
this.showWaterfall = true;
});
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 });
}
return this.$buefy.toast.open(response.message);
}
});
},
isAlbumSelected(id) {
if (!this.showingModalForFile) return;
const found = this.showingModalForFile.albums.find(el => el.id === id);
return found ? found.id ? true : false : false;
if (!this.showingModalForFile) return false;
const found = this.showingModalForFile.albums.find((el) => el.id === id);
return !!(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;
const response = await this.$axios.$get(`file/${file.id}/albums`);
this.showingModalForFile.albums = response.albums;
this.getAlbums();
},
async albumCheckboxClicked(value, id) {
const response = await this.$axios.$post(`file/album/${value ? 'add' : 'del'}`, {
albumId: id,
fileId: this.showingModalForFile.id
});
this.$buefy.toast.open(response.message);
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
});
}
// Not the prettiest solution to refetch on each click but it'll do for now
this.$parent.getFiles();
this.$buefy.toast.open(response.message);
} catch (e) {
this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
}
},
async getAlbums() {
const response = await this.$axios.$get(`albums/dropdown`);
this.albums = response.albums;
this.$forceUpdate();
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 .25s cubic-bezier(.55,0,.1,1);
@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;
}
}
div.toolbar {
padding: 1rem;
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;
}
.block {
text-align: right;
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;
-webkit-transition: opacity 0.1s linear;
-moz-transition: opacity 0.1s linear;
-ms-transition: opacity 0.1s linear;
-o-transition: opacity 0.1s linear;
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;
}
}
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: .75;
max-width: 150px;
}
&:nth-child(1),
&:nth-child(2) {
align-items: flex-end;
padding-bottom: 10px;
}
div.actions {
opacity: 0;
-webkit-transition: opacity 0.1s linear;
-moz-transition: opacity 0.1s linear;
-ms-transition: opacity 0.1s linear;
-o-transition: opacity 0.1s linear;
transition: opacity 0.1s linear;
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: calc(100% - 6px);
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
&:nth-child(3),
&:nth-child(4) {
justify-content: flex-end;
padding-bottom: 10px;
}
span {
padding: 3px;
&.more {
position: absolute;
top: 0;
right: 0;
}
&:nth-child(1), &:nth-child(2) {
align-items: flex-end;
}
&:nth-child(1), &:nth-child(3) {
justify-content: flex-end;
}
a {
a {
width: 30px;
height: 30px;
color: white;
justify-content: center;
align-items: center;
display: flex;
&.btn:before {
content: '';
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;
}
border: 1px solid white;
border-radius: 50%;
position: absolute;
}
}
}
&.fixed {
position: relative;
opacity: 1;
background: none;
&.fixed {
position: relative;
opacity: 1;
background: none;
a {
width: auto;
height: auto;
color: $defaultTextColor;
&:before {
display: 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;
}
}
</style>
<style lang="scss">
.waterfall-item:hover {
div.actions {
opacity: 1
.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>

View File

@ -1,180 +1,134 @@
<style>
.waterfall {
position: relative;
}
</style>
<template>
<div class="waterfall">
<slot />
<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 {quickSort, getMinIndex, _, sum} from './util'
import WaterfallItem from './WaterfallItem.vue';
const quickSort = (arr, type) => {
const left = [];
const right = [];
if (arr.length <= 1) {
return arr;
}
const povis = arr[0];
for (let i = 1; i < arr.length; i++) {
if (arr[i][type] < povis[type]) {
left.push(arr[i]);
} else {
right.push(arr[i]);
}
}
return quickSort(left, type).concat(povis, quickSort(right, type))
};
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;
const getMinIndex = arr => {
let pos = 0;
for (let i = 0; i < arr.length; i++) {
if (arr[pos] > arr[i]) {
pos = i;
}
}
return pos;
};
const _ = {
on(el, type, func, capture = false) {
el.addEventListener(type, func, capture);
},
off(el, type, func, capture = false) {
el.removeEventListener(type, func, capture);
}
};
const sum = arr => arr.reduce((sum, val) => sum + val);
export default {
name: 'Waterfall',
components: {
WaterfallItem
},
props: {
options: {
type: Object,
default: () => {}
},
items: {
type: Array,
default: () => []
},
itemWidth: {
type: Number,
default: 150
},
gutterWidth: {
type: Number,
default: 0
default: 10
},
gutterHeight: {
type: Number,
default: 0
},
resizable: {
type: Boolean,
default: true
},
align: {
type: String,
default: 'center'
},
fixWidth: {
type: Number
},
minCol: {
type: Number,
default: 1
},
maxCol: {
type: Number
},
percent: {
type: Array
default: 4
}
},
data() {
return {
timer: null,
colNum: 0,
lastWidth: 0,
percentWidthArr: []
};
},
created() {
this.$on('itemRender', () => {
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
this.render();
}, 0);
});
},
mounted() {
this.resizeHandle();
this.$watch('resizable', this.resizeHandle);
this.initializeMasonry();
this.imagesLoaded();
},
updated() {
this.performLayout();
this.imagesLoaded();
},
unmounted() {
this.masonry.destroy();
},
methods: {
calulate(arr) {
let pageWidth = this.fixWidth ? this.fixWidth : this.$el.offsetWidth;
//
if (this.percent) {
this.colNum = this.percent.length;
const total = sum(this.percent);
this.percentWidthArr = this.percent.map(value => (value / total) * pageWidth);
this.lastWidth = 0;
//
} else {
this.colNum = parseInt(pageWidth / (arr.width + this.gutterWidth));
if (this.minCol && this.colNum < this.minCol) {
this.colNum = this.minCol;
this.lastWidth = 0;
} else if (this.maxCol && this.colNum > this.maxCol) {
this.colNum = this.maxCol;
this.lastWidth = pageWidth - (arr.width + this.gutterWidth) * this.colNum + this.gutterWidth;
} else {
this.lastWidth = pageWidth - (arr.width + this.gutterWidth) * this.colNum + this.gutterWidth;
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) => !!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();
}
},
resizeHandle() {
if (this.resizable) {
_.on(window, 'resize', this.render, false);
} else {
_.off(window, 'resize', this.render, false);
}
},
render() {
//
let childArr = [];
childArr = this.$children.map(child => child.getMeta());
childArr = quickSort(childArr, 'order');
//
this.calulate(childArr[0])
let offsetArr = Array(this.colNum).fill(0);
//
childArr.forEach(child => {
let position = getMinIndex(offsetArr);
//
if (this.percent) {
let left = 0;
child.el.style.width = `${this.percentWidthArr[position]}px`;
if (position === 0) {
left = 0;
} else {
for (let i = 0; i < position; i++) {
left += this.percentWidthArr[i];
}
}
child.el.style.left = `${left}px`;
//
} else {
if (this.align === 'left') { // eslint-disable-line no-lonely-if
child.el.style.left = `${position * (child.width + this.gutterWidth)}px`;
} else if (this.align === 'right') {
child.el.style.left = `${position * (child.width + this.gutterWidth) + this.lastWidth}px`;
} else {
child.el.style.left = `${position * (child.width + this.gutterWidth) + this.lastWidth / 2}px`;
}
}
if (child.height === 0) {
return;
}
child.el.style.top = `${offsetArr[position]}px`;
offsetArr[position] += (child.height + this.gutterHeight);
this.$el.style.height = `${Math.max.apply(Math, offsetArr)}px`;
});
this.$emit('rendered', this);
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>
<style lang="scss" scoped>
.wfi {
}
</style>

View File

@ -1,60 +1,10 @@
<style>
.waterfall-item {
position: absolute;
}
</style>
<template>
<div class="waterfall-item">
<slot />
</div>
</template>
<script>
import imagesLoaded from 'imagesloaded';
export default {
name: 'WaterfallItem',
props: {
order: {
type: Number,
default: 0
},
width: {
type: Number,
default: 150
}
},
data() {
return {
itemWidth: 0,
height: 0
};
},
created() {
this.$watch(() => this.height, this.emit);
},
mounted() {
this.$el.style.display = 'none';
this.$el.style.width = `${this.width}px`;
this.emit();
imagesLoaded(this.$el, () => {
this.$el.style.left = '-9999px';
this.$el.style.top = '-9999px';
this.$el.style.display = 'block';
this.height = this.$el.offsetHeight;
this.itemWidth = this.$el.offsetWidth;
});
},
methods: {
emit() {
this.$parent.$emit('itemRender');
},
getMeta() {
return {
el: this.$el,
height: this.height,
width: this.itemWidth,
order: this.order
};
}
}
}
name: 'WaterfallItem'
};
</script>

View File

@ -1,6 +1,7 @@
<template>
<div class="links">
<a href="https://github.com/WeebDev/lolisafe"
<a
href="https://github.com/WeebDev/lolisafe"
target="_blank"
class="link">
<header class="bd-footer-star-header">
@ -8,15 +9,21 @@
<p class="bd-footer-subtitle">Deploy your own lolisafe</p>
</header>
</a>
<div v-if="loggedIn"
<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>
<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"
<a
href="https://chrome.google.com/webstore/detail/lolisafe-uploader/enkkmplljfjppcdaancckgilmgoiofnj"
target="_blank"
class="link">
<header class="bd-footer-star-header">
@ -24,17 +31,23 @@
<p class="bd-footer-subtitle">Upload from any website</p>
</header>
</a>
<router-link to="/faq"
<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>
<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() {

View File

@ -0,0 +1,92 @@
<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>
import { mapState } from 'vuex';
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 !!(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>

View File

@ -0,0 +1,210 @@
<template>
<div class="container has-background-lolisafe">
<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-lolisafe has-text-light">
File information
</div>
<b-field
label="ID"
label-position="on-border"
type="is-lolisafe"
class="lolisafe-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-lolisafe"
class="lolisafe-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-lolisafe"
class="lolisafe-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-lolisafe"
class="lolisafe-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-lolisafe"
class="lolisafe-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-lolisafe"
class="lolisafe-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-lolisafe"
class="lolisafe-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-lolisafe"
class="lolisafe-on-border">
<div class="control">
<span class="fake-input"><timeago :since="file.createdAt" /></span>
</div>
</b-field>
<div class="divider is-lolisafe has-text-light">
Tags
</div>
<Taginfo :imageId="file.id" :imageTags="tags" />
<div class="divider is-lolisafe has-text-light">
Albums
</div>
<Albuminfo :imageId="file.id" :imageAlbums="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));
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; /* $lolisafe */
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: start;
}
.data-col {
@media screen and (min-width: 769px) {
padding-right: 1.5rem;
}
@media screen and (max-width: 769px) {
padding-bottom: 3rem;
}
}
</style>

View File

@ -0,0 +1,95 @@
<template>
<b-field label="Add some tags">
<b-taginput
:value="selectedTags"
:data="filteredTags"
class="lolisafe 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>

View File

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

View File

@ -1,5 +1,6 @@
<template>
<div :style="styles"
<div
:style="styles"
class="spinner spinner--cube-shadow" />
</template>

View File

@ -1,7 +1,9 @@
<template>
<div :style="styles"
<div
:style="styles"
class="spinner spinner-origami">
<div :style="innerStyles"
<div
:style="innerStyles"
class="spinner-inner loading">
<span class="slice" />
<span class="slice" />
@ -23,7 +25,7 @@ export default {
},
computed: {
innerStyles() {
let size = parseInt(this.size);
const size = parseInt(this.size, 10);
return { transform: `scale(${(size / 60)})` };
},
styles() {

View File

@ -1,12 +1,14 @@
<template>
<div :style="styles"
<div
:style="styles"
class="spinner spinner--ping-pong">
<div :style="innerStyles"
<div
:style="innerStyles"
class="spinner-inner">
<div class="board">
<div class="left"/>
<div class="right"/>
<div class="ball"/>
<div class="left" />
<div class="right" />
<div class="ball" />
</div>
</div>
</div>
@ -22,7 +24,7 @@ export default {
},
computed: {
innerStyles() {
let size = parseInt(this.size);
const size = parseInt(this.size, 10);
return { transform: `scale(${size / 250})` };
},
styles() {
@ -32,7 +34,7 @@ export default {
};
}
}
}
};
</script>
<style lang="scss" scoped>

View File

@ -1,5 +1,6 @@
<template>
<div :style="styles"
<div
:style="styles"
class="spinner spinner--rotate-square-2" />
</template>

View File

@ -1,70 +1,74 @@
<template>
<nav :class="{ isWhite }"
class="navbar is-transparent">
<div class="navbar-brand">
<a role="button"
class="navbar-burger burger"
aria-label="menu"
aria-expanded="false"
data-target="navbarBasicExample">
<span aria-hidden="true" />
<span aria-hidden="true" />
<span aria-hidden="true" />
</a>
</div>
<div class="navbar-menu">
<div class="navbar-end">
<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>
<b-navbar-item tag="div">
<router-link
to="/"
to="/faq"
class="navbar-item no-active"
exact>
Docs
</router-link>
<template v-if="loggedIn">
</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>
</template>
<template v-else>
</b-navbar-item>
</template>
<template v-else>
<b-navbar-item tag="div">
<router-link
class="navbar-item"
to="/login">
Login
</router-link>
</template>
</div>
</div>
</nav>
</b-navbar-item>
</template>
</template>
</b-navbar>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
export default {
props: {
isWhite: {
@ -76,20 +80,18 @@ export default {
return { hamburger: false };
},
computed: {
loggedIn() {
return this.$store.state.loggedIn;
},
config() {
return this.$store.state.config;
}
...mapGetters({ loggedIn: 'auth/isLoggedIn' }),
...mapState(['config'])
},
methods: {
logOut() {
this.$store.dispatch('logout');
async logOut() {
await this.$store.dispatch('auth/logout');
this.$router.replace('/login');
}
}
};
</script>
<style lang="scss" scoped>
@import '~/assets/styles/_colors.scss';
nav.navbar {
@ -135,4 +137,8 @@ export default {
}
}
}
.no-active {
text-decoration: none !important;
}
</style>

View File

@ -0,0 +1,516 @@
<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 !!this.$scopedSlots.default;
},
/**
* Check if exists "empty" slot
*/
hasEmptySlot() {
return !!this.$slots.empty;
},
/**
* Check if exists "header" slot
*/
hasHeaderSlot() {
return !!this.$slots.header;
},
/**
* Check if exists "footer" slot
*/
hasFooterSlot() {
return !!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 = !!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 += trigger.clientHeight;
} else {
top -= dropdownMenu.clientHeight;
}
this.style = {
position: 'absolute',
top: `${top}px`,
left: `${left}px`,
width: `${trigger.clientWidth}px`,
maxWidth: `${trigger.clientWidth}px`,
zIndex: '99'
};
}
}
}
};
</script>

View File

@ -0,0 +1,136 @@
<template>
<div class="level-right">
<div class="level-item">
<b-field>
<SearchInput
ref="autocomplete"
v-model="query"
:data="filteredHints"
:customSelector="handleSelect"
field="name"
class="lolisafe-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-lolisafe" @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>

View File

@ -1,64 +1,80 @@
<template>
<div class="dashboard-menu">
<router-link to="/dashboard">
<i class="icon-com-pictures" />Files
</router-link>
<router-link to="/dashboard/albums">
<i class="icon-interface-window" />Albums
</router-link>
<!--
<router-link to="/dashboard/tags">
<i class="icon-ecommerce-tag-c" />Tags
</router-link>
-->
<router-link to="/dashboard/account">
<i class="icon-ecommerce-tag-c" />Account
</router-link>
<template v-if="user && user.isAdmin">
<router-link to="/dashboard/admin/users">
<i class="icon-setting-gear-a" />Users
</router-link>
<!--
TODO: Dont wanna deal with this now
<router-link to="/dashboard/admin/settings">
<i class="icon-setting-gear-a" />Settings
</router-link>
-->
</template>
</div>
<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: {
user() {
return this.$store.state.user;
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 {
padding: 2rem;
border-radius: 8px;
::v-deep a:hover {
cursor: pointer;
text-decoration: none;
}
a {
display: block;
font-weight: 700;
color: $textColor;
position: relative;
padding-left: 40px;
height: 35px;
&:hover{
color: white;
}
::v-deep .icon {
margin-right: 0.5rem;
}
i {
position: absolute;
font-size: 1.5em;
top: -4px;
left: 5px;
}
::v-deep .icon.is-pulled-right {
margin-right: 0;
}
hr { margin-top: 0.6em; }

View File

@ -1,7 +1,9 @@
<template>
<div :class="{ 'has-files': alreadyAddedFiles }"
<div
:class="{ 'has-files': alreadyAddedFiles }"
class="uploader-wrapper">
<b-select v-if="loggedIn"
<b-select
v-if="loggedIn"
v-model="selectedAlbum"
placeholder="Upload to album"
size="is-medium"
@ -13,7 +15,8 @@
{{ album.name }}
</option>
</b-select>
<dropzone v-if="showDropzone"
<dropzone
v-if="showDropzone"
id="dropzone"
ref="el"
:options="dropzoneOptions"
@ -25,16 +28,22 @@
Add or drop more files
</label>
<div id="template"
<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 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"
<a
class="link"
target="_blank">
Link
</a>
@ -43,14 +52,16 @@
<div class="error">
<div>
<span>
<span class="error-message"
<span
class="error-message"
data-dz-errormessage />
<i class="icon-web-warning" />
</span>
</div>
</div>
<div class="dz-progress">
<span class="dz-upload"
<span
class="dz-upload"
data-dz-uploadprogress />
</div>
<!--
@ -64,6 +75,8 @@
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import Dropzone from 'nuxt-dropzone';
import '~/assets/styles/dropzone.scss';
@ -75,20 +88,15 @@ export default {
files: [],
dropzoneOptions: {},
showDropzone: false,
albums: [],
selectedAlbum: null
};
},
computed: {
config() {
return this.$store.state.config;
},
token() {
return this.$store.state.token;
},
loggedIn() {
return this.$store.state.loggedIn;
}
...mapState({
config: (state) => state.config,
albums: (state) => state.albums.tinyDetails
}),
...mapGetters({ loggedIn: 'auth/isLoggedIn', token: 'auth/getToken' })
},
watch: {
loggedIn() {
@ -129,8 +137,11 @@ export default {
Get all available albums so the user can upload directly to one (or several soon) of them.
*/
async getAlbums() {
const response = await this.$axios.$get(`albums/dropdown`);
this.albums = response.albums;
try {
await this.$store.dispatch('albums/getTinyDetails');
} catch (e) {
this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
}
this.updateDropzoneConfig();
},
@ -150,7 +161,7 @@ export default {
/*
Dropzone stuff
*/
dropzoneFilesAdded(files) {
dropzoneFilesAdded() {
this.alreadyAddedFiles = true;
},
dropzoneSuccess(file, response) {
@ -161,6 +172,7 @@ export default {
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) {
@ -175,7 +187,6 @@ export default {
});
this.processResult(file, data);
this.$forceUpdate();
return done();
},

View File

@ -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'
};

View File

@ -1,73 +1,84 @@
<template>
<div v-bar>
<div>
<section class="hero is-fullheight has-text-centered">
<Navbar :isWhite="true" />
<div class="hero-body">
<nuxt-child id="app" />
</div>
<div class="hero-foot">
<Footer />
</div>
</section>
<div
v-bar
class="scroll-area">
<div class="default-body">
<Navbar :isWhite="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';
import Footer from '~/components/footer/Footer.vue';
export default {
components: { Navbar, Footer },
computed: {
config() {
return this.$store.state.config;
},
alert() {
return this.$store.state.alert;
}
components: {
Navbar,
Footer
},
watch: {
alert() {
if (!this.alert) return;
computed: mapState(['config', 'alert']),
created() {
this.$store.watch((state) => state.alert.message, () => {
const { message, type, snackbar } = this.alert;
this.$buefy.toast.open({
duration: 3500,
message: this.alert.text,
position: 'is-bottom',
type: this.alert.error ? 'is-danger' : 'is-success'
});
if (!message) return;
setTimeout(() => {
this.$store.dispatch('alert', null);
}, 3500);
}
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() {
console.log(`%c lolisafe %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');
// eslint-disable-next-line no-console
console.log(
`%c lolisafe %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">
html { overflow: hidden !important; }
.is-fullheight { height: 100vh; }
.hero-body {
padding: 3rem 0 !important;
#app {
width: 100%;
& > .container {
margin-top: 5rem;
}
}
> .hero {
min-height: auto !important;
height: auto !important;
}
}
@import "~/assets/styles/style.scss";
@import "~/assets/styles/icons.min.css";
html {
overflow: hidden !important;
}
.is-fullheight {
min-height: 100vh !important;
height: max-content;
}
.nuxt-app > .section {
min-height: auto !important;
height: auto !important;
}
@import '~/assets/styles/style.scss';
@import '~/assets/styles/icons.min.css';
</style>
<style lang="scss" scoped>
.hero-body {
align-items: baseline !important;
}
.default-body {
align-items: baseline !important;
}
.scroll-area {
height: 100vh;
}
</style>

View File

@ -1,5 +1,6 @@
export default function({ store, redirect }) {
export default function ({ store, redirect }) {
// If the user is not authenticated
if (!store.state.user) return redirect('/login');
if (!store.state.user.isAdmin) return redirect('/dashboard');
if (!store.state.auth.user) return redirect('/login');
if (!store.state.auth.user.isAdmin) return redirect('/dashboard');
return true;
}

View File

@ -1,6 +1,7 @@
export default function({ store, redirect }) {
export default function ({ store, redirect }) {
// If the user is not authenticated
if (!store.state.loggedIn) {
if (!store.state.auth.loggedIn) {
return redirect('/login');
}
return true;
}

View File

@ -16,42 +16,48 @@
</style>
<template>
<section class="hero is-fullheight">
<section class="section is-fullheight">
<template v-if="files && files.length">
<div class="hero-body align-top">
<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"
<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="hero-body">
<div class="container">
<Grid v-if="files && files.length"
:files="files"
:isPublic="true"
:width="200"
:enableSearch="false"
:enableToolbar="false" />
</div>
<div class="container">
<Grid
v-if="files && files.length"
:files="files"
:isPublic="true"
:width="200"
:enableSearch="false"
:enableToolbar="false" />
</div>
</template>
<template v-else>
<div class="hero-body">
<div class="container">
<h1 class="title">:(</h1>
<h2 class="subtitle">This album seems to be empty</h2>
</div>
<div class="container">
<h1 class="title">
:(
</h1>
<h2 class="subtitle">
This album seems to be empty
</h2>
</div>
</template>
</section>
</template>
<script>
import Grid from '~/components/grid/Grid.vue';
import axios from 'axios';
import Grid from '~/components/grid/Grid.vue';
export default {
components: { Grid },

View File

@ -1,64 +1,94 @@
<template>
<section class="hero is-fullheight dashboard">
<div class="hero-body">
<div class="container">
<div class="columns">
<div class="column is-narrow">
<Sidebar />
<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="lolisafe-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="lolisafe-input"
type="password"
expanded />
</b-field>
<b-field
label="New password"
message="Your new password"
horizontal>
<b-input
v-model="newPassword"
class="lolisafe-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="lolisafe-input"
type="password"
expanded />
</b-field>
<div class="mb2 mt2 text-center">
<b-button
type="is-lolisafe"
@click="changePassword">
Change password
</b-button>
</div>
<div class="column">
<h2 class="subtitle">Account settings</h2>
<hr>
<b-field label="Username"
message="Nothing to do here"
horizontal>
<b-input v-model="user.username"
<b-field
label="API key"
message="This API key lets you use the service from other apps"
horizontal>
<b-field expanded>
<b-input
class="lolisafe-input"
:value="apiKey"
expanded
disabled />
<p class="control">
<b-button
type="is-lolisafe"
@click="copyKey">
Copy
</b-button>
</p>
</b-field>
</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="user.password"
type="password"
expanded />
</b-field>
<b-field label="New password"
message="Your new password"
horizontal>
<b-input v-model="user.newPassword"
type="password"
expanded />
</b-field>
<b-field label="New password again"
message="Your new password once again"
horizontal>
<b-input v-model="user.reNewPassword"
type="password"
expanded />
</b-field>
<div class="mb2 mt2 text-center">
<button class="button is-primary"
@click="changePassword">Change password</button>
</div>
<b-field label="Api key"
message="This API key lets you use the service from other apps"
horizontal>
<b-input v-model="user.apiKey"
expanded
disabled />
</b-field>
<div class="mb2 mt2 text-center">
<button class="button is-primary"
@click="promptNewAPIKey">Request new API key</button>
</div>
<div class="mb2 mt2 text-center">
<b-button
type="is-lolisafe"
@click="promptNewAPIKey">
Request new API key
</b-button>
</div>
</div>
</div>
@ -67,51 +97,62 @@
</template>
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import Sidebar from '~/components/sidebar/Sidebar.vue';
export default {
components: {
Sidebar
},
middleware: 'auth',
middleware: ['auth', ({ store }) => {
store.dispatch('auth/fetchCurrentUser');
}],
data() {
return {
user: {}
password: '',
newPassword: '',
reNewPassword: ''
};
},
computed: {
...mapGetters({ 'apiKey': 'auth/getApiKey' }),
...mapState({
user: (state) => state.auth.user
})
},
metaInfo() {
return { title: 'Account' };
},
mounted() {
this.getUserSetttings();
},
methods: {
async getUserSetttings() {
const response = await this.$axios.$get(`users/me`);
this.user = response.user;
},
...mapActions({
getUserSetttings: 'auth/fetchCurrentUser'
}),
async changePassword() {
if (!this.user.password || !this.user.newPassword || !this.user.reNewPassword) {
this.$store.dispatch('alert', {
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 (this.user.newPassword !== this.user.reNewPassword) {
this.$store.dispatch('alert', {
if (newPassword !== reNewPassword) {
this.$store.dispatch('alert/set', {
text: 'Passwords don\'t match',
error: true
});
return;
}
const response = await this.$axios.$post(`user/password/change`,
{
password: this.user.password,
newPassword: this.user.newPassword
});
this.$buefy.toast.open(response.message);
const response = await this.$store.dispatch('auth/changePassword', {
password,
newPassword
});
if (response) {
this.$buefy.toast.open(response.message);
}
},
promptNewAPIKey() {
this.$buefy.dialog.confirm({
@ -120,10 +161,12 @@ export default {
onConfirm: () => this.requestNewAPIKey()
});
},
copyKey() {
this.$clipboard(this.apiKey);
this.$notifier.success('API key copied to clipboard');
},
async requestNewAPIKey() {
const response = await this.$axios.$post(`user/apikey/change`);
this.user.apiKey = response.apiKey;
this.$forceUpdate();
const response = await this.$store.dispatch('auth/requestAPIKey');
this.$buefy.toast.open(response.message);
}
}

View File

@ -2,96 +2,118 @@
.underline { text-decoration: underline; }
</style>
<template>
<section class="hero is-fullheight dashboard">
<div class="hero-body">
<div class="container">
<div class="columns">
<div class="column is-narrow">
<Sidebar />
<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="column">
<h2 class="subtitle">File details</h2>
<hr>
<div class="columns">
<div class="column is-6">
<b-field label="ID"
horizontal>
<span>{{ file.id }}</span>
</b-field>
<b-field label="Name"
horizontal>
<span>{{ file.name }}</span>
</b-field>
<b-field label="Original Name"
horizontal>
<span>{{ file.original }}</span>
</b-field>
<b-field label="IP"
horizontal>
<span class="underline">{{ file.ip }}</span>
</b-field>
<b-field label="Link"
horizontal>
<a :href="file.url"
target="_blank">{{ file.url }}</a>
</b-field>
<b-field label="Size"
horizontal>
<span>{{ formatBytes(file.size) }}</span>
</b-field>
<b-field label="Hash"
horizontal>
<span>{{ file.hash }}</span>
</b-field>
<b-field label="Uploaded"
horizontal>
<span><timeago :since="file.createdAt" /></span>
</b-field>
</div>
<div class="column is-6">
<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>
<nuxt-link :to="`/dashboard/admin/user/${user.id}`">{{ user.fileCount }}</nuxt-link>
</span>
</b-field>
</div>
</div>
<div class="mb2 mt2 text-center">
<button class="button is-danger"
@click="promptBanIP">Ban IP</button>
<button class="button is-danger"
@click="promptDisableUser">Disable user</button>
</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>
@ -100,59 +122,42 @@
</template>
<script>
import { mapState } from 'vuex';
import Sidebar from '~/components/sidebar/Sidebar.vue';
export default {
components: {
Sidebar
},
middleware: ['auth', 'admin'],
data() {
return {
options: {},
file: null,
user: null
};
},
async asyncData({ $axios, route }) {
middleware: ['auth', 'admin', ({ route, store }) => {
try {
const response = await $axios.$get(`file/${route.params.id}`);
return {
file: response.file ? response.file : null,
user: response.user ? response.user : null
};
} catch (error) {
console.error(error);
return {
file: null,
user: null
};
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()
});
},
async disableUser() {
const response = await this.$axios.$post('admin/users/disable', {
id: this.user.id
});
this.$buefy.toast.open(response.message);
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()
});
},
async banIP() {
const response = await this.$axios.$post('admin/ban/ip', {
ip: this.file.ip
});
this.$buefy.toast.open(response.message);
banIP() {
this.$handler.executeAction('admin/banIP', this.file.ip);
},
formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
@ -163,7 +168,7 @@ export default {
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}
}
};

View File

@ -1,94 +1,123 @@
<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">Service settings</h2>
<hr>
<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="options.serviceName"
expanded />
</b-field>
<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="lolisafe-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="options.uploadFolder"
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="lolisafe-input"
expanded />
</b-field>
<b-field label="Links per album"
message="Maximum links allowed per album"
horizontal>
<b-input v-model="options.linksPerAlbum"
type="number"
expanded />
</b-field>
<b-field
label="Links per album"
message="Maximum links allowed per album"
horizontal>
<b-input
v-model="settings.linksPerAlbum"
class="lolisafe-input"
type="number"
expanded />
</b-field>
<b-field label="Max upload size"
message="Maximum allowed file size in MB"
horizontal>
<b-input v-model="options.maxUploadSize"
expanded />
</b-field>
<b-field
label="Max upload size"
message="Maximum allowed file size in MB"
horizontal>
<b-input
v-model="settings.maxUploadSize"
class="lolisafe-input"
expanded />
</b-field>
<b-field label="Filename length"
message="How many characters long should the generated filenames be"
horizontal>
<b-input v-model="options.filenameLength"
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="lolisafe-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="options.albumLinkLength"
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="lolisafe-input"
expanded />
</b-field>
<b-field label="Generate thumbnails"
message="Generate thumbnails when uploading a file if possible"
horizontal>
<b-switch v-model="options.generateThumbnails"
:true-value="true"
:false-value="false" />
</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="options.generateZips"
: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="options.publicMode"
: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="options.enableAccounts"
: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 class="mb2 mt2 text-center">
<button
class="button is-primary"
@click="promptRestartService">
Save and restart service
</button>
</div>
</div>
</div>
@ -97,38 +126,36 @@
</template>
<script>
import { mapState } from 'vuex';
import Sidebar from '~/components/sidebar/Sidebar.vue';
export default {
components: {
Sidebar
},
middleware: ['auth', 'admin'],
data() {
return {
options: {}
};
},
middleware: ['auth', 'admin', ({ store }) => {
try {
store.dispatch('admin/fetchSettings');
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
}],
metaInfo() {
return { title: 'Settings' };
},
mounted() {
this.getSettings();
},
computed: mapState({
settings: (state) => state.admin.settings
}),
methods: {
async getSettings() {
const response = await this.$axios.$get(`service/config`);
this.options = response.config;
},
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()
});
},
async restartService() {
const response = await this.$axios.$post(`service/restart`);
this.$buefy.toast.open(response.message);
restartService() {
this.$handler.executeAction('admin/restartService');
}
}
};

View File

@ -2,50 +2,66 @@
.underline { text-decoration: underline; }
</style>
<template>
<section class="hero is-fullheight dashboard">
<div class="hero-body">
<div class="container">
<div class="columns">
<div class="column is-narrow">
<Sidebar />
<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>
<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>{{ files.length }}</span>
</b-field>
<div class="mb2 mt2 text-center">
<button class="button is-danger"
@click="promptDisableUser">Disable user</button>
</div>
<Grid v-if="files.length"
:files="files" />
</div>
<Grid
v-if="user.files.length"
:files="user.files" />
</div>
</div>
</div>
@ -53,6 +69,7 @@
</template>
<script>
import { mapState } from 'vuex';
import Sidebar from '~/components/sidebar/Sidebar.vue';
import Grid from '~/components/grid/Grid.vue';
@ -61,41 +78,42 @@ export default {
Sidebar,
Grid
},
middleware: ['auth', 'admin'],
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: {},
files: null,
user: null
options: {}
};
},
async asyncData({ $axios, route }) {
try {
const response = await $axios.$get(`/admin/users/${route.params.id}`);
return {
files: response.files ? response.files : null,
user: response.user ? response.user : null
};
} catch (error) {
console.error(error);
return {
files: null,
user: null
};
}
},
computed: mapState({
user: (state) => state.admin.user
}),
methods: {
promptDisableUser() {
this.$buefy.dialog.confirm({
message: 'Are you sure you want to disable the account of the user that uploaded this file?',
type: 'is-danger',
message: 'Are you sure you want to disable the account of this user?',
onConfirm: () => this.disableUser()
});
},
async disableUser() {
const response = await this.$axios.$post('admin/users/disable', {
id: this.user.id
promptEnableUser() {
this.$buefy.dialog.confirm({
type: 'is-danger',
message: 'Are you sure you want to enable the account of this user?',
onConfirm: () => this.enableUser()
});
this.$buefy.toast.open(response.message);
},
disableUser() {
this.$handler.executeAction('admin/disableUser', this.user.id);
},
enableUser() {
this.$handler.executeAction('admin/enableUser', this.user.id);
}
}
};

View File

@ -1,3 +1,141 @@
<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 {
@ -107,9 +245,6 @@
}
div.column > h2.subtitle { padding-top: 1px; }
</style>
<style lang="scss">
@import '~/assets/styles/_colors.scss';
.b-table {
.table-wrapper {
@ -118,132 +253,3 @@
}
}
</style>
<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 users</h2>
<hr>
<div class="view-container">
<b-table
:data="users || []"
:mobile-cards="true">
<template slot-scope="props">
<b-table-column field="id"
label="Id"
centered>
{{ props.row.id }}
</b-table-column>
<b-table-column 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 field="enabled"
label="Enabled"
centered>
<b-switch v-model="props.row.enabled"
@input="changeEnabledStatus(props.row)" />
</b-table-column>
<b-table-column field="isAdmin"
label="Admin"
centered>
<b-switch v-model="props.row.isAdmin"
@input="changeIsAdmin(props.row)" />
</b-table-column>
<b-table-column field="purge"
centered>
<button class="button is-primary"
@click="promptPurgeFiles(props.row)">Purge files</button>
</b-table-column>
</template>
<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>
</div>
</section>
</template>
<script>
import Sidebar from '~/components/sidebar/Sidebar.vue';
export default {
components: {
Sidebar
},
middleware: ['auth', 'admin'],
data() {
return {
users: []
};
},
computed: {
config() {
return this.$store.state.config;
}
},
metaInfo() {
return { title: 'Uploads' };
},
mounted() {
this.getUsers();
},
methods: {
async getUsers() {
const response = await this.$axios.$get(`admin/users`);
this.users = response.users;
},
async changeEnabledStatus(row) {
const response = await this.$axios.$post(`admin/users/${row.enabled ? 'enable' : 'disable'}`, {
id: row.id
});
this.$buefy.toast.open(response.message);
},
async changeIsAdmin(row) {
const response = await this.$axios.$post(`admin/users/${row.isAdmin ? 'promote' : 'demote'}`, {
id: row.id
});
this.$buefy.toast.open(response.message);
},
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) {
const response = await this.$axios.$post(`admin/users/purge`, {
id: row.id
});
this.$buefy.toast.open(response.message);
}
}
};
</script>

View File

@ -3,20 +3,58 @@
</style>
<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">Files</h2>
<hr>
<!-- TODO: Add a list view so the user can see the files that don't have thumbnails, like text documents -->
<Grid v-if="files.length"
:files="files" />
</div>
<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>
@ -24,34 +62,67 @@
</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
Grid,
Search
},
middleware: 'auth',
middleware: ['auth', ({ route, store }) => {
store.commit('images/resetState');
store.dispatch('images/fetchByAlbumId', { id: route.params.id });
}],
data() {
return {
name: null,
files: []
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' };
},
async asyncData({ $axios, route }) {
try {
const response = await $axios.$get(`album/${route.params.id}/full`);
return {
files: response.files ? response.files : []
};
} catch (error) {
console.error(error);
return { files: [] };
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>

View File

@ -7,130 +7,36 @@
<Sidebar />
</div>
<div class="column">
<h2 class="subtitle">Manage your albums</h2>
<h2 class="subtitle">
Manage your albums
</h2>
<hr>
<div class="search-container">
<b-field>
<b-input v-model="newAlbumName"
<b-input
v-model="newAlbumName"
class="lolisafe-input"
placeholder="Album name..."
type="text"
@keyup.enter.native="createAlbum" />
<p class="control">
<button class="button is-primary"
@click="createAlbum">Create album</button>
<button
outlined
class="button is-black"
:disabled="isCreatingAlbum"
@click="createAlbum">
Create album
</button>
</p>
</b-field>
</div>
<div class="view-container">
<div v-for="album in albums"
<AlbumEntry
v-for="album in albums.list"
:key="album.id"
class="album">
<div class="arrow-container"
@click="fetchAlbumDetails(album)">
<i :class="{ active: album.isDetailsOpen }"
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/albums/${album.id}`">{{ album.name }}</router-link>
</h4>
<span>Updated <timeago :since="album.editedAt" /></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>
<div v-if="album.isDetailsOpen"
class="details">
<h2>Public links for this album:</h2>
<b-table
:data="album.links.length ? album.links : []"
:mobile-cards="true">
<template slot-scope="props">
<b-table-column 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 field="views"
label="Views"
centered>
{{ props.row.views }}
</b-table-column>
<b-table-column field="enableDownload"
label="Allow download"
centered>
<b-switch v-model="props.row.enableDownload"
@input="linkOptionsChanged(props.row)" />
</b-table-column>
<b-table-column field="enabled"
label="Actions"
centered>
<button class="button is-danger"
@click="promptDeleteAlbumLink(props.row.identifier)">Delete link</button>
</b-table-column>
</template>
<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="wrapper">
<div class="has-text-right">
<button :class="{ 'is-loading': album.isCreatingLink }"
class="button is-primary"
style="float: left"
@click="createLink(album)">Create new link</button>
{{ album.links.length }} / {{ config.maxLinksPerAlbum }} links created
</div>
<div class="has-text-left">
<button class="button is-danger"
style="float: right"
@click="promptDeleteAlbum(album.id)">Delete album</button>
</div>
</div>
</template>
</b-table>
</div>
</div>
:album="album" />
</div>
</div>
</div>
@ -140,223 +46,65 @@
</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
Sidebar,
AlbumEntry
},
middleware: 'auth',
middleware: ['auth', ({ store }) => {
try {
store.dispatch('albums/fetch');
} catch (e) {
this.alert({ text: e.message, error: true });
}
}],
data() {
return {
albums: [],
newAlbumName: null
newAlbumName: null,
isCreatingAlbum: false
};
},
computed: {
config() {
return this.$store.state.config;
}
},
computed: mapState(['config', 'albums']),
metaInfo() {
return { title: 'Uploads' };
},
mounted() {
this.getAlbums();
},
methods: {
async fetchAlbumDetails(album) {
const response = await this.$axios.$get(`album/${album.id}/links`);
album.links = response.links;
album.isDetailsOpen = !album.isDetailsOpen;
this.$forceUpdate();
},
promptDeleteAlbum(id) {
this.$buefy.dialog.confirm({
message: 'Are you sure you want to delete this album?',
onConfirm: () => this.deleteAlbum(id)
});
},
async deleteAlbum(id) {
const response = await this.$axios.$delete(`album/${id}`);
this.getAlbums();
return this.$buefy.toast.open(response.message);
},
promptDeleteAlbumLink(identifier) {
this.$buefy.dialog.confirm({
message: 'Are you sure you want to delete this album link?',
onConfirm: () => this.deleteAlbumLink(identifier)
});
},
async deleteAlbumLink(identifier) {
const response = await this.$axios.$delete(`album/link/delete/${identifier}`);
return this.$buefy.toast.open(response.message);
},
async linkOptionsChanged(link) {
const response = await this.$axios.$post(`album/link/edit`,
{
identifier: link.identifier,
enableDownload: link.enableDownload,
enabled: link.enabled
});
this.$buefy.toast.open(response.message);
},
async createLink(album) {
album.isCreatingLink = true;
// Since we actually want to change the state even if the call fails, use a try catch
try {
const response = await this.$axios.$post(`album/link/new`,
{ albumId: album.id });
this.$buefy.toast.open(response.message);
album.links.push({
identifier: response.identifier,
views: 0,
enabled: true,
enableDownload: true,
expiresAt: null
});
} catch (error) {
//
} finally {
album.isCreatingLink = false;
}
},
...mapActions({
'alert': 'alert/set'
}),
async createAlbum() {
if (!this.newAlbumName || this.newAlbumName === '') return;
const response = await this.$axios.$post(`album/new`,
{ name: this.newAlbumName });
this.newAlbumName = null;
this.$buefy.toast.open(response.message);
this.getAlbums();
},
async getAlbums() {
const response = await this.$axios.$get(`albums/mini`);
for (const album of response.albums) {
album.isDetailsOpen = false;
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;
}
this.albums = response.albums;
}
}
};
</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;
-webkit-transition: transform 0.1s linear;
-moz-transition: transform 0.1s linear;
-ms-transition: transform 0.1s linear;
-o-transition: transform 0.1s linear;
transition: transform 0.1s linear;
&.active {
transform: rotate(-45deg);
}
}
}
div.thumb {
width: 64px;
height: 64px;
-webkit-box-shadow: $boxShadowLight;
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.details {
flex: 0 1 100%;
padding-left: 2em;
padding-top: 1em;
min-height: 50px;
.b-table {
padding: 2em 0em;
.table-wrapper {
-webkit-box-shadow: $boxShadowLight;
box-shadow: $boxShadowLight;
}
}
}
div.search-container {
padding: 1rem 2rem;
background-color: $base-2;
}
div.column > h2.subtitle { padding-top: 1px; }
</style>
<style lang="scss">
@import '~/assets/styles/_colors.scss';
.b-table {
.table-wrapper {
-webkit-box-shadow: $boxShadowLight;
box-shadow: $boxShadowLight;
}
}
</style>

View File

@ -1,19 +1,52 @@
<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">Your uploaded files</h2>
<hr>
<!-- TODO: Add a list view so the user can see the files that don't have thumbnails, like text documents -->
<Grid v-if="files.length"
:files="files"
:enableSearch="false" />
</div>
<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"
:enableSearch="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>
@ -21,31 +54,90 @@
</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
Grid,
Search
},
middleware: 'auth',
middleware: ['auth', ({ store }) => {
store.commit('images/resetState');
store.dispatch('images/fetch');
}],
data() {
return {
files: []
current: 1,
isLoading: false,
search: ''
};
},
computed: {
...mapGetters({
totalFiles: 'images/getTotalFiles',
shouldPaginate: 'images/shouldPaginate',
limit: 'images/getLimit'
}),
...mapState(['images'])
},
metaInfo() {
return { title: 'Uploads' };
},
mounted() {
this.getFiles();
watch: {
current: 'fetchPaginate'
},
created() {
this.filteredHints = this.hints; // fixes the issue where on pageload, suggestions wont load
},
methods: {
async getFiles() {
const response = await this.$axios.$get(`files`);
this.files = response.files;
...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);
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>

View File

@ -107,6 +107,10 @@
}
div.column > h2.subtitle { padding-top: 1px; }
div.no-background {
background: none;
}
</style>
<style lang="scss">
@import '~/assets/styles/_colors.scss';
@ -119,77 +123,85 @@
}
</style>
<template>
<section class="hero is-fullheight dashboard">
<div class="hero-body">
<div class="container">
<div class="columns">
<div class="column is-narrow">
<Sidebar />
<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="lolisafe-input"
placeholder="Tag name..."
type="text"
@keyup.enter.native="createTag" />
<p class="control">
<b-button
type="is-lolisafe"
@click="createTag">
Create tags
</b-button>
</p>
</b-field>
</div>
<div class="column">
<h2 class="subtitle">Manage your tags</h2>
<hr>
<div class="search-container">
<b-field>
<b-input v-model="newTagName"
placeholder="Tag name..."
type="text"
@keyup.enter.native="createTag" />
<p class="control">
<button class="button is-primary"
@click="createTag">Create tags</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 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>
@ -226,12 +238,14 @@ export default {
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',
@ -246,14 +260,14 @@ export default {
},
async createTag() {
if (!this.newTagName || this.newTagName === '') return;
const response = await this.$axios.$post(`tag/new`,
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`);
const response = await this.$axios.$get('tags');
for (const tag of response.tags) {
tag.isDetailsOpen = false;
}

View File

@ -1,34 +1,45 @@
<template>
<!-- eslint-disable max-len -->
<div class="container has-text-left">
<h2 class="subtitle">What is lolisafe?</h2>
<h2 class="subtitle">
What is lolisafe?
</h2>
<article class="message">
<div class="message-body">
lolisafe 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 lolisafe?</h2>
<h2 class="subtitle">
Can I run my own lolisafe?
</h2>
<article class="message">
<div class="message-body">
Definitely. Head to <a target="_blank" href="https://github.com/WeebDev/lolisafe">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>
<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>
<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/loli-safe-uploader/enkkmplljfjppcdaancckgilmgoiofnj">our chrome extension</a> which will enable you to <strong>right click -&gt; send to lolisafe</strong> or to a desired album if you have any.
</div>
</article>
<h2 class="subtitle">Why should I use this?</h2>
<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, lolisafe 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.
@ -48,6 +59,7 @@ export default {
}
};
</script>
<style lang="scss" scoped>
@import '~/assets/styles/_colors.scss';
article.message { background-color: #ffffff; }

View File

@ -1,5 +1,5 @@
<template>
<div>
<div class="section">
<div class="container">
<div class="columns">
<div class="column is-3 is-offset-2">
@ -11,15 +11,18 @@
<div class="content-wrapper">
<h4>Blazing fast file uploader. <br>For real.</h4>
<p>
<!-- eslint-disable-next-line max-len -->
A <strong>modern</strong> and <strong>self-hosted</strong> 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.
</p>
</div>
</div>
</div>
</div>
<div class="container">
<div class="container uploader">
<Uploader v-if="config.publicMode || (!config.publicMode && loggedIn)" />
<div v-else>
<div
v-else
class="has-text-centered is-size-4 has-text-danger">
This site has disabled public uploads. You need an account.
</div>
<Links />
@ -27,6 +30,8 @@
</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';
@ -42,12 +47,8 @@ export default {
return { albums: [] };
},
computed: {
loggedIn() {
return this.$store.state.loggedIn;
},
config() {
return this.$store.state.config;
}
...mapGetters({ loggedIn: 'auth/isLoggedIn' }),
...mapState(['config'])
}
};
</script>
@ -79,4 +80,8 @@ export default {
}
}
}
.uploader {
margin-top: 2rem;
}
</style>

View File

@ -1,37 +1,57 @@
<template>
<section class="hero is-fullheight is-login">
<div class="hero-body">
<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"
type="text"
placeholder="Username"
@keyup.enter.native="login" />
</b-field>
<b-field>
<b-input v-model="password"
type="password"
placeholder="Password"
password-reveal
@keyup.enter.native="login" />
</b-field>
<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="lolisafe-input"
type="text"
placeholder="Username"
@keyup.enter.native="login" />
</b-field>
<b-field>
<b-input
v-model="password"
class="lolisafe-input"
type="password"
placeholder="Password"
password-reveal
@keyup.enter.native="login" />
</b-field>
<p class="control has-addons is-pulled-right">
<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>
<button class="button is-primary big ml1"
@click="login">login</button>
</p>
<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-lolisafe"
@click="login">
Login
</b-button>
</p>
</div>
</div>
</div>
</div>
@ -65,6 +85,8 @@
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'Login',
data() {
@ -76,38 +98,33 @@ export default {
isLoading: false
};
},
computed: {
config() {
return this.$store.state.config;
}
},
computed: mapState(['config', 'auth']),
metaInfo() {
return { title: 'Login' };
},
created() {
if (this.auth.loggedIn) {
this.redirect();
}
},
methods: {
async login() {
if (this.isLoading) return;
if (!this.username || !this.password) {
this.$store.dispatch('alert', {
text: 'Please fill both fields before attempting to log in.',
error: true
});
const { username, password } = this;
if (!username || !password) {
this.$notifier.error('Please fill both fields before attempting to log in.');
return;
}
this.isLoading = true;
try {
const data = await this.$axios.$post(`auth/login`, {
username: this.username,
password: this.password
});
this.$axios.setToken(data.token, 'Bearer');
document.cookie = `token=${encodeURIComponent(data.token)}`;
this.$store.dispatch('login', { token: data.token, user: data.user });
this.redirect();
} catch (error) {
//
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;
}
@ -126,9 +143,8 @@ export default {
this.isLoading = false;
this.$onPromiseError(err);
});
},*/
}, */
redirect() {
this.$store.commit('loggedIn', true);
if (typeof this.$route.query.redirect !== 'undefined') {
this.$router.push(this.$route.query.redirect);
return;

View File

@ -0,0 +1,8 @@
<script>
export default {
async created() {
await this.$store.dispatch('auth/logout');
this.$router.replace('/login');
}
};
</script>

View File

@ -1,41 +1,62 @@
<template>
<section class="hero is-fullheight is-register">
<div class="hero-body">
<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"
type="text"
placeholder="Username" />
</b-field>
<b-field>
<b-input v-model="password"
type="password"
placeholder="Password"
password-reveal />
</b-field>
<b-field>
<b-input v-model="rePassword"
type="password"
placeholder="Re-type Password"
password-reveal
@keyup.enter.native="register" />
</b-field>
<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="lolisafe-input"
type="text"
placeholder="Username" />
</b-field>
<b-field>
<b-input
v-model="password"
class="lolisafe-input"
type="password"
placeholder="Password"
password-reveal />
</b-field>
<b-field>
<b-input
v-model="rePassword"
class="lolisafe-input"
type="password"
placeholder="Re-type Password"
password-reveal
@keyup.enter.native="register" />
</b-field>
<p class="control has-addons is-pulled-right">
<router-link to="/login"
class="is-text">Already have an account?</router-link>
<button class="button is-primary big ml1"
:disabled="isLoading"
@click="register">Register</button>
</p>
<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-lolisafe"
:disabled="isLoading"
@click="register">
Register
</b-button>
</p>
</div>
</div>
</div>
</div>
@ -44,6 +65,8 @@
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'Register',
data() {
@ -54,43 +77,35 @@ export default {
isLoading: false
};
},
computed: {
config() {
return this.$store.state.config;
}
},
computed: mapState(['config', 'auth']),
metaInfo() {
return { title: 'Register' };
},
methods: {
async register() {
if (this.isLoading) return;
if (!this.username || !this.password || !this.rePassword) {
this.$store.dispatch('alert', {
text: 'Please fill all fields before attempting to register.',
error: true
});
this.$notifier.error('Please fill all fields before attempting to register.');
return;
}
if (this.password !== this.rePassword) {
this.$store.dispatch('alert', {
text: 'Passwords don\'t match',
error: true
});
this.$notifier.error('Passwords don\'t match');
return;
}
this.isLoading = true;
try {
const response = await this.$axios.$post(`auth/register`, {
const response = await this.$store.dispatch('auth/register', {
username: this.username,
password: this.password
});
this.$store.dispatch('alert', { text: response.message });
return this.$router.push('/login');
this.$notifier.success(response.message);
this.$router.push('/login');
return;
} catch (error) {
//
this.$notifier.error(error.message);
} finally {
this.isLoading = false;
}

View File

@ -1,18 +1,32 @@
export default function({ $axios, store }) {
export default function ({ $axios, store }) {
$axios.setHeader('accept', 'application/vnd.lolisafe.json');
$axios.onRequest(config => {
if (store.state.token) {
config.headers.common['Authorization'] = `bearer ${store.state.token}`;
$axios.onRequest((config) => {
if (store.state.auth.token) {
config.headers.common.Authorization = `bearer ${store.state.auth.token}`;
}
});
$axios.onError(error => {
if (process.env.development) console.error('[AXIOS Error]', error);
$axios.onError((error) => {
if (process.env.NODE_ENV !== 'production') console.error('[AXIOS Error]', error);
if (process.browser) {
store.dispatch('alert', {
text: error.response.data.message,
error: true
});
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');
} */
}
});
}

View File

@ -1,4 +1,5 @@
import Vue from 'vue';
import Buefy from 'buefy';
// import 'buefy/dist/buefy.css';
Vue.use(Buefy);

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