diff options
| -rw-r--r-- | src/api/database/migrations/20190221225812_initialMigration.js | 24 | ||||
| -rw-r--r-- | src/api/database/migrations/20190221225813_addTags.js | 21 | ||||
| -rw-r--r-- | src/api/routes/admin/banIP.js | 25 | ||||
| -rw-r--r-- | src/api/routes/admin/unBanIP.js | 27 | ||||
| -rw-r--r-- | src/api/routes/admin/userGET.js | 32 | ||||
| -rw-r--r-- | src/api/routes/files/fileGET.js | 29 | ||||
| -rw-r--r-- | src/api/structures/Route.js | 5 | ||||
| -rw-r--r-- | src/site/assets/images/blank.png | bin | 466 -> 1892 bytes | |||
| -rw-r--r-- | src/site/components/grid/Grid.vue | 290 | ||||
| -rw-r--r-- | src/site/components/sidebar/Sidebar.vue | 4 | ||||
| -rw-r--r-- | src/site/pages/a/_identifier.vue | 3 | ||||
| -rw-r--r-- | src/site/pages/dashboard/admin/file/_id.vue | 170 | ||||
| -rw-r--r-- | src/site/pages/dashboard/admin/settings.vue (renamed from src/site/pages/dashboard/settings.vue) | 2 | ||||
| -rw-r--r-- | src/site/pages/dashboard/admin/user/_id.vue | 102 | ||||
| -rw-r--r-- | src/site/pages/dashboard/admin/users.vue (renamed from src/site/pages/dashboard/users.vue) | 4 |
15 files changed, 642 insertions, 96 deletions
diff --git a/src/api/database/migrations/20190221225812_initialMigration.js b/src/api/database/migrations/20190221225812_initialMigration.js index a9ce2c7..84bda7e 100644 --- a/src/api/database/migrations/20190221225812_initialMigration.js +++ b/src/api/database/migrations/20190221225812_initialMigration.js @@ -62,6 +62,27 @@ exports.up = async knex => { table.integer('albumId'); table.integer('linkId'); }); + + await knex.schema.createTable('tags', table => { + table.increments(); + table.string('uuid'); + table.integer('userId'); + table.string('name'); + table.timestamp('createdAt'); + table.timestamp('editedAt'); + }); + + await knex.schema.createTable('fileTags', table => { + table.increments(); + table.integer('fileId'); + table.integer('tagId'); + }); + + await knex.schema.createTable('bans', table => { + table.increments(); + table.string('ip'); + table.timestamp('createdAt'); + }); }; exports.down = async knex => { await knex.schema.dropTableIfExists('users'); @@ -70,4 +91,7 @@ exports.down = async knex => { await knex.schema.dropTableIfExists('links'); await knex.schema.dropTableIfExists('albumsFiles'); await knex.schema.dropTableIfExists('albumsLinks'); + await knex.schema.dropTableIfExists('tags'); + await knex.schema.dropTableIfExists('fileTags'); + await knex.schema.dropTableIfExists('bans'); }; diff --git a/src/api/database/migrations/20190221225813_addTags.js b/src/api/database/migrations/20190221225813_addTags.js deleted file mode 100644 index ef71877..0000000 --- a/src/api/database/migrations/20190221225813_addTags.js +++ /dev/null @@ -1,21 +0,0 @@ -exports.up = async knex => { - await knex.schema.createTable('tags', table => { - table.increments(); - table.string('uuid'); - table.integer('userId'); - table.string('name'); - table.timestamp('createdAt'); - table.timestamp('editedAt'); - }); - - await knex.schema.createTable('fileTags', table => { - table.increments(); - table.integer('fileId'); - table.integer('tagId'); - }); -}; - -exports.down = async knex => { - await knex.schema.dropTableIfExists('tags'); - await knex.schema.dropTableIfExists('fileTags'); -}; diff --git a/src/api/routes/admin/banIP.js b/src/api/routes/admin/banIP.js new file mode 100644 index 0000000..692880d --- /dev/null +++ b/src/api/routes/admin/banIP.js @@ -0,0 +1,25 @@ +const Route = require('../../structures/Route'); + +class banIP extends Route { + constructor() { + super('/admin/ban/ip', 'post', { adminOnly: true }); + } + + async run(req, res, db) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { ip } = req.body; + if (!ip) return res.status(400).json({ message: 'No ip provided' }); + + try { + await db.table('bans').insert({ ip }); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully banned the ip' + }); + } +} + +module.exports = banIP; diff --git a/src/api/routes/admin/unBanIP.js b/src/api/routes/admin/unBanIP.js new file mode 100644 index 0000000..493834b --- /dev/null +++ b/src/api/routes/admin/unBanIP.js @@ -0,0 +1,27 @@ +const Route = require('../../structures/Route'); + +class unBanIP extends Route { + constructor() { + super('/admin/unban/ip', 'post', { adminOnly: true }); + } + + async run(req, res, db) { + if (!req.body) return res.status(400).json({ message: 'No body provided' }); + const { ip } = req.body; + if (!ip) return res.status(400).json({ message: 'No ip provided' }); + + try { + await db.table('bans') + .where({ ip }) + .delete(); + } catch (error) { + return super.error(res, error); + } + + return res.json({ + message: 'Successfully unbanned the ip' + }); + } +} + +module.exports = unBanIP; diff --git a/src/api/routes/admin/userGET.js b/src/api/routes/admin/userGET.js new file mode 100644 index 0000000..895a565 --- /dev/null +++ b/src/api/routes/admin/userGET.js @@ -0,0 +1,32 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +class usersGET extends Route { + constructor() { + super('/admin/users/:id', 'get', { adminOnly: true }); + } + + async run(req, res, db) { + const { id } = req.params; + if (!id) return res.status(400).json({ message: 'Invalid user ID supplied' }); + + try { + const user = await db.table('users').where({ id }).first(); + const files = await db.table('files').where({ userId: user.id }); + + for (let file of files) { + file = Util.constructFilePublicLink(file); + } + + return res.json({ + message: 'Successfully retrieved user', + user, + files + }); + } catch (error) { + return super.error(res, error); + } + } +} + +module.exports = usersGET; diff --git a/src/api/routes/files/fileGET.js b/src/api/routes/files/fileGET.js new file mode 100644 index 0000000..3bb8da4 --- /dev/null +++ b/src/api/routes/files/fileGET.js @@ -0,0 +1,29 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +class filesGET extends Route { + constructor() { + super('/file/:id', 'get', { adminOnly: true }); + } + + async run(req, res, db) { + const { id } = req.params; + if (!id) return res.status(400).json({ message: 'Invalid file ID supplied' }); + + let file = await db.table('files').where({ id }).first(); + const user = await db.table('users').where({ id: file.userId }).first(); + file = Util.constructFilePublicLink(file); + + // Additional relevant data + const filesFromUser = await db.table('files').where({ userId: user.id }).select('id'); + user.fileCount = filesFromUser.length; + + return res.json({ + message: 'Successfully retrieved file', + file, + user + }); + } +} + +module.exports = filesGET; diff --git a/src/api/structures/Route.js b/src/api/structures/Route.js index c04c585..2db9bc6 100644 --- a/src/api/structures/Route.js +++ b/src/api/structures/Route.js @@ -52,7 +52,10 @@ class Route { this.options = options || {}; } - authorize(req, res) { + async authorize(req, res) { + const banned = await db.table('bans').where({ ip: req.ip }).first(); + if (banned) return res.status(401).json({ message: 'This IP has been banned from using the service.' }); + if (this.options.bypassAuth) return this.run(req, res, db); if (req.headers.apiKey) return this.authorizeApiKey(req, res, req.headers.apiKey); if (!req.headers.authorization) return res.status(401).json({ message: 'No authorization header provided' }); diff --git a/src/site/assets/images/blank.png b/src/site/assets/images/blank.png Binary files differindex 8615039..224a81c 100644 --- a/src/site/assets/images/blank.png +++ b/src/site/assets/images/blank.png diff --git a/src/site/components/grid/Grid.vue b/src/site/components/grid/Grid.vue index 19acde9..2e2f911 100644 --- a/src/site/components/grid/Grid.vue +++ b/src/site/components/grid/Grid.vue @@ -4,6 +4,30 @@ transition: all .25s cubic-bezier(.55,0,.1,1); -webkit-transition: all .25s cubic-bezier(.55,0,.1,1); } + + div.toolbar { + padding: 1rem; + + .block { + text-align: right; + } + } + + span.extension { + position: absolute; + width: 100%; + height: 100%; + z-index: 0; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + pointer-events: none; + opacity: .75; + } + div.actions { opacity: 0; -webkit-transition: opacity 0.1s linear; @@ -23,6 +47,11 @@ span { padding: 3px; + &.more { + position: absolute; + top: 0; + right: 0; + } &:nth-child(1), &:nth-child(2) { align-items: flex-end; @@ -38,7 +67,7 @@ justify-content: center; align-items: center; display: flex; - &:before { + &.btn:before { content: ''; width: 30px; height: 30px; @@ -76,64 +105,174 @@ </style> <template> - <Waterfall - :gutterWidth="10" - :gutterHeight="4"> - <!-- Gotta implement search and pagination here --> - <input v-if="enableSearch" - v-model="searchTerm" - type="text" - placeholder="Search..." - @input="search()" - @keyup.enter="search()"> - - <WaterfallItem v-for="(item, index) in files" - v-if="showWaterfall && item.thumb" - :key="index" - :width="width" - move-class="item-move"> - <template v-if="isPublic"> - <a :href="`${item.url}`" - target="_blank"> - <img :src="`${item.thumb}`"> - </a> - </template> - <template v-else> - <img :src="`${item.thumb}`"> - <div v-if="!isPublic" - :class="{ fixed }" - class="actions"> - <b-tooltip label="Link" - position="is-top"> - <a :href="`${item.url}`" - target="_blank"> - <i class="icon-web-code" /> - </a> - </b-tooltip> - <b-tooltip label="Albums" - position="is-top"> - <a @click="$parent.openAlbumModal(item)"> - <i class="icon-interface-window" /> - </a> - </b-tooltip> - <!-- - <b-tooltip label="Tags" - position="is-top"> - <a @click="manageTags(item)"> - <i class="icon-ecommerce-tag-c" /> - </a> - </b-tooltip> - --> - <b-tooltip label="Delete" - position="is-top"> - <a @click="deleteFile(item, index)"> - <i class="icon-editorial-trash-a-l" /> - </a> - </b-tooltip> - </div> - </template> - </WaterfallItem> - </Waterfall> + <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> + </div> + </div> + + <Waterfall v-if="!showList" + :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 files" + v-if="showWaterfall" + :key="index" + :width="width" + move-class="item-move"> + <template v-if="isPublic"> + <a :href="`${item.url}`" + target="_blank"> + <img :src="item.thumb ? item.thumb : blank"> + </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" + :class="{ fixed }" + class="actions"> + <b-tooltip label="Link" + position="is-top"> + <a :href="`${item.url}`" + target="_blank" + class="btn"> + <i class="icon-web-code" /> + </a> + </b-tooltip> + <b-tooltip label="Albums" + position="is-top"> + <a class="btn" + @click="$parent.openAlbumModal(item)"> + <i class="icon-interface-window" /> + </a> + </b-tooltip> + <!-- + <b-tooltip label="Tags" + position="is-top"> + <a @click="manageTags(item)"> + <i class="icon-ecommerce-tag-c" /> + </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"> + <nuxt-link :to="`/dashboard/admin/file/${item.id}`"> + <i class="icon-interface-more" /> + </nuxt-link> + </b-tooltip> + </div> + </template> + </WaterfallItem> + </Waterfall> + <div v-else> + <b-table + :data="files || []" + :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-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="$parent.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> + </template> + </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"> + {{ files.length }} files + </div> + </template> + </b-table> + </div> + </div> </template> <script> import Waterfall from './waterfall/Waterfall.vue'; @@ -164,16 +303,26 @@ export default { enableSearch: { type: Boolean, default: true + }, + enableToolbar: { + type: Boolean, + default: true } }, data() { return { showWaterfall: true, - searchTerm: null + searchTerm: null, + showList: false }; }, - mounted() { - this.$search.items(this.files); + computed: { + user() { + return this.$store.state.user; + }, + blank() { + return require('@/assets/images/blank.png'); + } }, methods: { async search() { @@ -194,12 +343,17 @@ export default { hasIcon: true, onConfirm: async () => { const response = await this.$axios.$delete(`file/${file.id}`); - this.showWaterfall = false; - this.files.splice(index, 1); - this.$nextTick(() => { - this.showWaterfall = true; - }); - return this.buefy.$toast.open(response.message); + if (this.showList) { + file.hideFromList = true; + this.$forceUpdate(); + } else { + this.showWaterfall = false; + this.files.splice(index, 1); + this.$nextTick(() => { + this.showWaterfall = true; + }); + } + return this.$buefy.toast.open(response.message); } }); } diff --git a/src/site/components/sidebar/Sidebar.vue b/src/site/components/sidebar/Sidebar.vue index 101a70b..9fe1be5 100644 --- a/src/site/components/sidebar/Sidebar.vue +++ b/src/site/components/sidebar/Sidebar.vue @@ -46,12 +46,12 @@ </router-link> <template v-if="user && user.isAdmin"> <hr> - <router-link to="/dashboard/users"> + <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/settings"> + <router-link to="/dashboard/admin/settings"> <i class="icon-setting-gear-a" />Settings </router-link> --> diff --git a/src/site/pages/a/_identifier.vue b/src/site/pages/a/_identifier.vue index 923e8cc..62035b3 100644 --- a/src/site/pages/a/_identifier.vue +++ b/src/site/pages/a/_identifier.vue @@ -35,7 +35,8 @@ :files="files" :isPublic="true" :width="200" - :enableSearch="false" /> + :enableSearch="false" + :enableToolbar="false" /> </div> </div> </template> diff --git a/src/site/pages/dashboard/admin/file/_id.vue b/src/site/pages/dashboard/admin/file/_id.vue new file mode 100644 index 0000000..6718b32 --- /dev/null +++ b/src/site/pages/dashboard/admin/file/_id.vue @@ -0,0 +1,170 @@ +<style lang="scss" scoped> + .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 /> + </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> + </div> + </div> + </div> + </section> +</template> + +<script> +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 }) { + 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 + }; + } + }, + methods: { + promptDisableUser() { + this.$buefy.dialog.confirm({ + 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); + }, + promptBanIP() { + this.$buefy.dialog.confirm({ + 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); + }, + 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 / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; + } + } +}; +</script> diff --git a/src/site/pages/dashboard/settings.vue b/src/site/pages/dashboard/admin/settings.vue index 35a23e8..052a641 100644 --- a/src/site/pages/dashboard/settings.vue +++ b/src/site/pages/dashboard/admin/settings.vue @@ -103,7 +103,7 @@ export default { components: { Sidebar }, - middleware: 'admin', + middleware: ['auth', 'admin'], data() { return { options: {} diff --git a/src/site/pages/dashboard/admin/user/_id.vue b/src/site/pages/dashboard/admin/user/_id.vue new file mode 100644 index 0000000..7703b1c --- /dev/null +++ b/src/site/pages/dashboard/admin/user/_id.vue @@ -0,0 +1,102 @@ +<style lang="scss" scoped> + .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 /> + </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> + </div> + </div> + </div> + </section> +</template> + +<script> +import Sidebar from '~/components/sidebar/Sidebar.vue'; +import Grid from '~/components/grid/Grid.vue'; + +export default { + components: { + Sidebar, + Grid + }, + middleware: ['auth', 'admin'], + data() { + return { + options: {}, + files: null, + user: null + }; + }, + 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 + }; + } + }, + methods: { + promptDisableUser() { + this.$buefy.dialog.confirm({ + 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); + } + } +}; +</script> diff --git a/src/site/pages/dashboard/users.vue b/src/site/pages/dashboard/admin/users.vue index 66ccebe..1fefa1e 100644 --- a/src/site/pages/dashboard/users.vue +++ b/src/site/pages/dashboard/admin/users.vue @@ -146,7 +146,7 @@ <b-table-column field="username" label="Username" centered> - {{ props.row.username }} + <nuxt-link :to="`/dashboard/admin/user/${props.row.id}`">{{ props.row.username }}</nuxt-link> </b-table-column> <b-table-column field="enabled" @@ -192,7 +192,7 @@ </template> <script> -import Sidebar from '../../components/sidebar/Sidebar.vue'; +import Sidebar from '~/components/sidebar/Sidebar.vue'; export default { components: { |