Build plugin "report-textures" into core

This commit is contained in:
Pig Fang 2019-03-30 11:38:30 +08:00
parent d8d9923224
commit 9a095732fa
36 changed files with 1073 additions and 16 deletions

View File

@ -128,6 +128,12 @@ class AdminController extends Controller
$form->text('user_initial_score');
})->handle();
$report = Option::form('report', OptionForm::AUTO_DETECT, function ($form) {
$form->text('reporter_score_modification')->description();
$form->text('reporter_reward_score');
})->handle();
$sign = Option::form('sign', OptionForm::AUTO_DETECT, function ($form) {
$form->group('sign_score')
->text('sign_score_from')->addon(trans('options.sign.sign_score.addon1'))
@ -154,7 +160,7 @@ class AdminController extends Controller
->addon(trans('general.user.score'));
})->handle();
return view('admin.score', ['forms' => compact('rate', 'sign', 'sharing')]);
return view('admin.score', ['forms' => compact('rate', 'report', 'sign', 'sharing')]);
}
public function options()
@ -215,10 +221,13 @@ class AdminController extends Controller
$form->text('texture_name_regexp')->hint()->placeholder();
$form->textarea('content_policy')->rows(3)->description();
$form->textarea('comment_script')->rows(6)->description();
})->handle(function () {
Option::set('site_name_'.config('app.locale'), request('site_name'));
Option::set('site_description_'.config('app.locale'), request('site_description'));
Option::set('content_policy_'.config('app.locale'), request('content_policy'));
});
$announ = Option::form('announ', OptionForm::AUTO_DETECT, function ($form) {

View File

@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Report;
use App\Models\Texture;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class ReportController extends Controller
{
public function submit(Request $request)
{
$data = $this->validate($request, [
'tid' => 'required|exists:textures',
'reason' => 'required'
]);
$reporter = auth()->user();
if (Report::where('reporter', $reporter->uid)->where('tid', $data['tid'])->count() > 0) {
return json(trans('skinlib.report.duplicate'), 1);
}
$score = option('reporter_score_modification', 0);
if ($score < 0 && $reporter->score < -$score) {
return json(trans('skinlib.upload.lack-score'), 1);
}
$reporter->score += $score;
$reporter->save();
$report = new Report;
$report->tid = $data['tid'];
$report->uploader = Texture::find($data['tid'])->uploader;
$report->reporter = $reporter->uid;
$report->reason = $data['reason'];
$report->status = Report::PENDING;
$report->save();
return json(trans('skinlib.report.success'), 0);
}
public function viewTrack()
{
return view('user.report', ['user' => auth()->user()]);
}
public function track()
{
return Report::where('reporter', auth()->id())
->orderBy('report_at', 'desc')
->get();
}
public function manage(Request $request)
{
$search = $request->input('search', '');
$sortField = $request->input('sortField', 'report_at');
$sortType = $request->input('sortType', 'desc');
$page = $request->input('page', 1);
$perPage = $request->input('perPage', 10);
$reports = Report::where('tid', 'like', '%'.$search.'%')
->orWhere('reporter', 'like', '%'.$search.'%')
->orWhere('reason', 'like', '%'.$search.'%')
->orderBy($sortField, $sortType)
->offset(($page - 1) * $perPage)
->limit($perPage)
->get()
->makeHidden(['informer'])
->map(function ($report) {
$uploader = User::find($report->uploader);
if ($uploader) {
$report->uploaderName = $uploader->nickname;
}
if ($report->informer) {
$report->reporterName = $report->informer->nickname;
}
return $report;
});
return [
'totalRecords' => Report::count(),
'data' => $reports,
];
}
public function review(Request $request)
{
$data = $this->validate($request, [
'id' => 'required|exists:reports',
'action' => ['required', Rule::in(['delete', 'ban', 'reject'])]
]);
$report = Report::find($data['id']);
if ($report->status != Report::PENDING) {
return json(trans('admin.report-reviewed'), 1);
}
if ($data['action'] == 'reject') {
if (
$report->informer &&
($score = option('reporter_score_modification', 0)) > 0
) {
$report->informer->score -= $score;
$report->informer->save();
}
$report->status = Report::REJECTED;
$report->save();
return json(trans('general.op-success'), 0, ['status' => Report::REJECTED]);
}
switch ($data['action']) {
case 'delete':
$report->texture->delete();
break;
case 'ban':
if (auth()->user()->permission <= $report->informer->permission) {
return json(trans('admin.users.operations.no-permission'), 1);
}
$report->informer->permission = User::BANNED;
$report->informer->save();
break;
}
$report->status = Report::RESOLVED;
$report->save();
if (($score = option('reporter_score_modification', 0)) < 0) {
$report->informer->score -= $score;
}
$report->informer->score += option('reporter_reward_score', 0);
$report->informer->save();
return json(trans('general.op-success'), 0, ['status' => Report::RESOLVED]);
}
}

View File

@ -250,7 +250,14 @@ class SetupController extends Controller
public static function checkTablesExist($tables = [], $returnExistingTables = false)
{
$existingTables = [];
$tables = $tables ?: ['users', 'user_closet', 'players', 'textures', 'options'];
$tables = $tables ?: [
'users',
'user_closet',
'players',
'textures',
'options',
'reports',
];
foreach ($tables as $tableName) {
if (Schema::hasTable($tableName)) {

View File

@ -157,7 +157,8 @@ class SkinlibController extends Controller
'currentUid' => $user ? $user->uid : 0,
'admin' => $user && $user->isAdmin(),
'inCloset' => $user && $user->closet()->where('tid', $texture->tid)->count() > 0,
'nickname' => ($up = User::find($texture->uploader)) ? $up->nickname : null
'nickname' => ($up = User::find($texture->uploader)) ? $up->nickname : null,
'report' => intval(option('reporter_score_modification', 0)),
]);
}
@ -185,6 +186,7 @@ class SkinlibController extends Controller
'scorePublic' => intval(option('score_per_storage')),
'scorePrivate' => intval(option('private_score_per_storage')),
'award' => intval(option('score_award_per_texture')),
'contentPolicy' => app('parsedown')->text(option_localized('content_policy')),
])
->with('with_out_filter', true);
}

32
app/Models/Report.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Report extends Model
{
public const CREATED_AT = 'report_at';
public const UPDATED_AT = null;
public const PENDING = 0;
public const RESOLVED = 1;
public const REJECTED = 2;
protected $casts = [
'tid' => 'integer',
'uploader' => 'integer',
'reporter' => 'integer',
'status' => 'integer',
];
public function texture()
{
return $this->belongsTo(Texture::class, 'tid', 'tid');
}
public function informer()
{
return $this->belongsTo(User::class, 'reporter', 'uid');
}
}

View File

@ -12,6 +12,7 @@ $menu['user'] = [
['title' => 'general.dashboard', 'link' => 'user', 'icon' => 'fa-tachometer-alt'],
['title' => 'general.my-closet', 'link' => 'user/closet', 'icon' => 'fa-star'],
['title' => 'general.player-manage', 'link' => 'user/player', 'icon' => 'fa-users'],
['title' => 'general.my-reports', 'link' => 'user/reports', 'icon' => 'fa-flag'],
['title' => 'general.profile', 'link' => 'user/profile', 'icon' => 'fa-user'],
];
@ -19,6 +20,7 @@ $menu['admin'] = [
['title' => 'general.dashboard', 'link' => 'admin', 'icon' => 'fa-tachometer-alt'],
['title' => 'general.user-manage', 'link' => 'admin/users', 'icon' => 'fa-users'],
['title' => 'general.player-manage', 'link' => 'admin/players', 'icon' => 'fa-gamepad'],
['title' => 'general.report-manage', 'link' => 'admin/reports', 'icon' => 'fa-flag'],
['title' => 'general.customize', 'link' => 'admin/customize', 'icon' => 'fa-paint-brush'],
['title' => 'general.score-options', 'link' => 'admin/score', 'icon' => 'fa-credit-card'],
['title' => 'general.options', 'link' => 'admin/options', 'icon' => 'fa-cog'],

View File

@ -53,4 +53,7 @@ return [
'recaptcha_sitekey' => '',
'recaptcha_secretkey' => '',
'recaptcha_invisible' => 'false',
'reporter_score_modification' => '0',
'reporter_reward_score' => '0',
'content_policy' => '',
];

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateReportTable extends Migration
{
public function up()
{
Schema::create('reports', function (Blueprint $table) {
$table->increments('id');
$table->integer('tid');
$table->integer('uploader');
$table->integer('reporter');
$table->longText('reason');
$table->integer('status');
$table->dateTime('report_at');
});
}
public function down()
{
Schema::dropIfExists('reports');
}
}

View File

@ -27,9 +27,9 @@ export default Vue.extend({
this.serverParams.perPage = params.currentPerPage
this.fetchData()
},
onSortChange(params: { sortType: 'asc' | 'desc', columnIndex: number }) {
this.serverParams.sortType = params.sortType
this.serverParams.sortField = this.columns[params.columnIndex].field
onSortChange([params]: { type: 'asc' | 'desc', field: string }[]) {
this.serverParams.sortType = params.type
this.serverParams.sortField = params.field
this.fetchData()
},
onSearch(params: { searchTerm: string }) {

View File

@ -23,6 +23,11 @@ export default [
component: () => import('../views/user/Bind.vue'),
el: 'form',
},
{
path: 'user/reports',
component: () => import('../views/user/Report.vue'),
el: '.content',
},
{
path: 'user/profile',
component: () => import('../views/user/Profile.vue'),
@ -42,6 +47,11 @@ export default [
component: () => import('../views/admin/Players.vue'),
el: '.content',
},
{
path: 'admin/reports',
component: () => import('../views/admin/Reports.vue'),
el: '.content',
},
{
path: 'admin/customize',
component: () => import('../views/admin/Customization.vue'),

View File

@ -0,0 +1,139 @@
<template>
<section class="content">
<vue-good-table
mode="remote"
:rows="reports"
:total-rows="totalRecords || reports.length"
:columns="columns"
:search-options="tableOptions.search"
:pagination-options="tableOptions.pagination"
style-class="vgt-table striped"
@on-page-change="onPageChange"
@on-sort-change="onSortChange"
@on-search="onSearch"
@on-per-page-change="onPerPageChange"
>
<template #table-row="props">
<span v-if="props.column.field === 'tid'">
{{ props.formattedRow[props.column.field] }}
<a :href="`${baseUrl}/skinlib/show/${props.row.tid}`">{{ $t('report.check') }}</a>
<a href="#" @click="deleteTexture(props.row)">
{{ $t('report.delete') }}
</a>
</span>
<span v-else-if="props.column.field === 'uploader'">
{{ props.row.uploaderName }} (UID: {{ props.row.uploader }})
<a href="#" @click="ban(props.row)">
{{ $t('report.ban') }}
</a>
</span>
<span v-else-if="props.column.field === 'reporter'">
{{ props.row.reporterName }} (UID: {{ props.row.reporter }})
</span>
<span v-else-if="props.column.field === 'status'">
{{ $t(`report.status.${props.row.status}`) }}
</span>
<span v-else-if="props.column.field === 'ops'">
<el-button size="medium" @click="reject(props.row)">
{{ $t('report.reject') }}
</el-button>
</span>
<span v-else>
{{ props.formattedRow[props.column.field] }}
</span>
</template>
</vue-good-table>
</section>
</template>
<script>
import { VueGoodTable } from 'vue-good-table'
import 'vue-good-table/dist/vue-good-table.min.css'
import tableOptions from '../../components/mixins/tableOptions'
import serverTable from '../../components/mixins/serverTable'
export default {
name: 'ReportsManagement',
components: {
VueGoodTable,
},
mixins: [
tableOptions,
serverTable,
],
props: {
baseUrl: {
type: String,
default: blessing.base_url,
},
},
data() {
return {
reports: [],
columns: [
{
field: 'id', type: 'number', hidden: true,
},
{
field: 'tid', label: this.$t('report.tid'), type: 'number',
},
{ field: 'uploader', label: this.$t('skinlib.show.uploader') },
{ field: 'reporter', label: this.$t('report.reporter') },
{
field: 'reason',
label: this.$t('report.reason'),
sortable: false,
width: '23%',
},
{ field: 'status', label: this.$t('report.status-title') },
{
field: 'report_at',
label: this.$t('report.time'),
globalSearchDisabled: true,
},
{
field: 'ops',
label: this.$t('admin.operationsTitle'),
globalSearchDisabled: true,
},
],
}
},
mounted() {
this.fetchData()
},
methods: {
async fetchData() {
const { data, totalRecords } = await this.$http.get(
'/admin/report-data',
this.serverParams
)
this.totalRecords = totalRecords
this.reports = data
},
deleteTexture(report) {
this.resolve(report, 'delete')
},
ban(report) {
this.resolve(report, 'ban')
},
reject(report) {
this.resolve(report, 'reject')
},
async resolve(report, action) {
const {
errno, msg, status,
} = await this.$http.post(
'/admin/reports',
{ id: report.id, action }
)
if (errno === 0) {
this.$message.success(msg)
report.status = status
} else {
this.$message.warning(msg)
}
},
},
}
</script>

View File

@ -52,6 +52,14 @@
:href="`${baseUrl}/raw/${tid}.png`"
:download="`${name}`.png"
/>
<el-button
type="warning"
size="medium"
data-test="report"
@click="report"
>
{{ $t('skinlib.report.title') }}
</el-button>
</template>
<div
class="btn likes pull-right"
@ -181,6 +189,7 @@ export default {
currentUid: blessing.extra.currentUid,
admin: blessing.extra.admin,
uploaderNickName: blessing.extra.nickname,
reportScore: blessing.extra.report,
}
},
computed: {
@ -320,6 +329,35 @@ export default {
this.$message.warning(msg)
}
},
async report() {
const message = (() => {
if (this.reportScore > 0) {
return this.$t('skinlib.report.positive', { score: this.reportScore })
} else if (this.reportScore < 0) {
return this.$t('skinlib.report.negative', { score: -this.reportScore })
}
return ''
})()
let reason
try {
({ value: reason } = await this.$prompt(message, {
title: this.$t('skinlib.report.title'),
inputPlaceholder: this.$t('skinlib.report.reason'),
}))
} catch {
return
}
const { errno, msg } = await this.$http.post(
'/skinlib/report',
{ tid: this.tid, reason }
)
if (errno === 0) {
this.$message.success(msg)
} else {
this.$message.warning(msg)
}
},
},
}
</script>

View File

@ -46,7 +46,11 @@
{{ $t('skinlib.upload.remove') }}
</el-button>
</div>
</div><!-- /.box-body -->
<a href="#" @click="showContentPolicy">
{{ $t('skinlib.showContentPolicy') }}
</a>
</div>
<div class="box-footer">
<el-switch
@ -118,6 +122,7 @@ export default {
scorePublic: blessing.extra.scorePublic,
scorePrivate: blessing.extra.scorePrivate,
award: blessing.extra.award,
contentPolicy: blessing.extra.contentPolicy,
width2d: 64,
}
},
@ -186,6 +191,11 @@ export default {
this.$refs.upload.clear()
this.texture = ''
},
showContentPolicy() {
this.$alert(this.contentPolicy, {
dangerouslyUseHTMLString: true,
})
},
},
}
</script>

View File

@ -0,0 +1,80 @@
<template>
<section class="content">
<vue-good-table
:rows="reports"
:columns="columns"
:search-options="tableOptions.search"
:pagination-options="tableOptions.pagination"
style-class="vgt-table striped"
>
<template #table-row="props">
<span v-if="props.column.field === 'tid'">
{{ props.formattedRow[props.column.field] }}
<a :href="`${baseUrl}/skinlib/show/${props.row.tid}`">
<i class="fa fa-share" />
</a>
</span>
<span v-else-if="props.column.field === 'status'">
{{ $t(`report.status.${props.row.status}`) }}
</span>
<span v-else>
{{ props.formattedRow[props.column.field] }}
</span>
</template>
</vue-good-table>
</section>
</template>
<script>
import { VueGoodTable } from 'vue-good-table'
import 'vue-good-table/dist/vue-good-table.min.css'
import tableOptions from '../../components/mixins/tableOptions'
export default {
name: 'MyReports',
components: {
VueGoodTable,
},
mixins: [
tableOptions,
],
props: {
baseUrl: {
type: String,
default: blessing.base_url,
},
},
data() {
return {
reports: [],
columns: [
{
field: 'id', label: 'ID', type: 'number',
},
{
field: 'tid', label: this.$t('report.tid'), type: 'number',
},
{
field: 'reason',
label: this.$t('report.reason'),
sortable: false,
},
{ field: 'status', label: this.$t('report.status-title') },
{
field: 'report_at',
label: this.$t('report.time'),
globalSearchDisabled: true,
},
],
}
},
mounted() {
this.fetchData()
},
methods: {
async fetchData() {
this.reports = await this.$http.get('/user/report-list')
},
},
}
</script>

View File

@ -27,7 +27,7 @@ test('change sort type', () => {
{ field: '2' },
],
})
wrapper.vm.onSortChange({ sortType: 'desc', columnIndex: 2 })
wrapper.vm.onSortChange([{ type: 'desc', field: '2' }])
expect(wrapper.vm.fetchData).toBeCalled()
expect(wrapper.vm.serverParams.sortType).toBe('desc')
expect(wrapper.vm.serverParams.sortField).toBe('2')

View File

@ -0,0 +1,99 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import Reports from '@/views/admin/Reports.vue'
import { flushPromises } from '../../utils'
test('basic render', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [{
id: 1,
uploader: 1,
uploaderName: 'a',
reporter: 2,
reporterName: 'b',
reason: 'sth',
status: 0,
}],
})
const wrapper = mount(Reports)
await wrapper.vm.$nextTick()
const text = wrapper.text()
expect(text).toContain('a (UID: 1)')
expect(text).toContain('b (UID: 2)')
expect(text).toContain('sth')
expect(text).toContain('report.status.0')
})
test('link to skin library', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [{ id: 1, tid: 1 }],
})
const wrapper = mount(Reports)
await wrapper.vm.$nextTick()
expect(wrapper.find('a').attributes('href')).toBe('/skinlib/show/1')
})
test('delete texture', async () => {
Vue.prototype.$http.get.mockResolvedValue({ data: [{ id: 1, status: 0 }] })
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: 'fail' })
.mockResolvedValue({
errno: 0, msg: 'ok', status: 1,
})
const wrapper = mount(Reports)
await wrapper.vm.$nextTick()
const button = wrapper.findAll('a').at(1)
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/reports',
{ id: 1, action: 'delete' }
)
expect(Vue.prototype.$message.warning).toBeCalledWith('fail')
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$message.success).toBeCalledWith('ok')
expect(wrapper.text()).toContain('report.status.1')
})
test('ban uploader', async () => {
Vue.prototype.$http.get.mockResolvedValue({ data: [{ id: 1, status: 0 }] })
Vue.prototype.$http.post
.mockResolvedValue({
errno: 0, msg: 'ok', status: 1,
})
const wrapper = mount(Reports)
await wrapper.vm.$nextTick()
const button = wrapper.findAll('a').at(2)
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/reports',
{ id: 1, action: 'ban' }
)
expect(Vue.prototype.$message.success).toBeCalledWith('ok')
expect(wrapper.text()).toContain('report.status.1')
})
test('reject', async () => {
Vue.prototype.$http.get.mockResolvedValue({ data: [{ id: 1, status: 0 }] })
Vue.prototype.$http.post
.mockResolvedValue({
errno: 0, msg: 'ok', status: 2,
})
const wrapper = mount(Reports)
await wrapper.vm.$nextTick()
const button = wrapper.find('button')
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/reports',
{ id: 1, action: 'reject' }
)
expect(Vue.prototype.$message.success).toBeCalledWith('ok')
expect(wrapper.text()).toContain('report.status.2')
})

View File

@ -358,3 +358,52 @@ test('delete texture', async () => {
jest.runAllTimers()
expect(Vue.prototype.$message.success).toBeCalledWith('0')
})
test('report texture', async () => {
Vue.prototype.$http.get.mockResolvedValue({ report: 0 })
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: 'duplicated' })
.mockResolvedValue({ errno: 0, msg: 'success' })
Vue.prototype.$prompt
.mockRejectedValueOnce('')
.mockRejectedValueOnce('')
.mockResolvedValue({ value: 'reason' } as MessageBoxData)
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1'],
},
stubs: { previewer },
})
const button = wrapper.find('[data-test=report]')
button.trigger('click')
expect(Vue.prototype.$prompt).toBeCalledWith('', {
title: 'skinlib.report.title',
inputPlaceholder: 'skinlib.report.reason',
})
expect(Vue.prototype.$http.post).not.toBeCalled()
wrapper.setData({ reportScore: -5 })
button.trigger('click')
expect(Vue.prototype.$prompt).toBeCalledWith('skinlib.report.negative', {
title: 'skinlib.report.title',
inputPlaceholder: 'skinlib.report.reason',
})
wrapper.setData({ reportScore: 5 })
button.trigger('click')
expect(Vue.prototype.$prompt).toBeCalledWith('skinlib.report.positive', {
title: 'skinlib.report.title',
inputPlaceholder: 'skinlib.report.reason',
})
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/skinlib/report',
{ tid: 1, reason: 'reason' }
)
expect(Vue.prototype.$message.warning).toBeCalledWith('duplicated')
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$message.success).toBeCalledWith('success')
})

View File

@ -10,6 +10,7 @@ window.blessing.extra = {
scorePrivate: 10,
scorePublic: 1,
award: 0,
contentPolicy: 'the policy',
}
const csrf = document.createElement('meta')
@ -167,3 +168,11 @@ test('show notice about awarding', () => {
wrapper.find('[type=checkbox]').setChecked()
expect(wrapper.find('.callout-success').exists()).toBeFalse()
})
test('show content policy', () => {
const wrapper = mount(Upload)
wrapper.find('a').trigger('click')
expect(Vue.prototype.$alert).toBeCalledWith('the policy', {
dangerouslyUseHTMLString: true,
})
})

View File

@ -0,0 +1,16 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import Report from '@/views/user/Report.vue'
test('basic render', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{
id: 1, tid: 1, reason: 'abc', status: 1,
},
])
const wrapper = mount(Report)
await wrapper.vm.$nextTick()
expect(wrapper.find('a').attributes('href')).toBe('/skinlib/show/1')
expect(wrapper.text()).toContain('report.status.1')
})

View File

@ -146,4 +146,6 @@ update:
unzip: "Failed to extract update package. Error code: "
overwrite: Unable to overwrite files.
report-reviewed: This report has been processed.
invalid-action: Invalid action

View File

@ -50,7 +50,7 @@ skinlib:
setNewTextureName: 'Please enter the new texture name:'
emptyNewTextureName: Empty new texture name.
seeMyUpload: My Uploads
apply: Quick Apply
apply: Apply
filter:
skin: (Any Model)
steve: (Steve)
@ -69,6 +69,7 @@ skinlib:
emptyUploadFile: You have not uploaded any file.
encodingError: 'Error: Encoding of this file is not accepted.'
fileExtError: 'Error: Textures should be PNG files.'
showContentPolicy: Read content policy
uploading: Uploading
redirecting: Redirecting...
setAsPrivate: Set as Private
@ -98,9 +99,14 @@ skinlib:
size: File Size
uploader: Uploader
upload-at: Upload At
download: Download Texture
download: Download
delete-texture: Delete Texture
manage-notice: The texture which was deleted or setted to private will be removed from the closet of everyone who had favorited it.
report:
title: Report
reason: Tell us reason please.
positive: To encourage positive contributions to the skinlib, we will reward who reported inappropriate content with :score scores. However, if any malicious reporting behaviors were found, all scores rewarded will be taken back.
negative: To mitigate the impact of malicious reports, we will require :score scores for submitting a texture report. Don't worry. The suspended scores and additional reward will be sent to your account after your report reviewed by administrators.
user:
signRemainingTime: 'Available after :time :unit'
@ -309,6 +315,21 @@ admin:
black: Black
black-light: Black Light
report:
tid: Texture ID
reporter: Reporter
reason: Reason
status-title: Status
status:
- Pending
- Resolved
- Rejected
time: Report Time
check: Details
delete: Delete
ban: Ban
reject: Reject
general:
skin: Skin
cape: Cape

View File

@ -12,8 +12,10 @@ anonymous: Guest
back: Back
dashboard: Dashboard
my-closet: Closet
my-reports: Reports
player-manage: Players
user-manage: Users
report-manage: Reports
plugin-manage: Plugins
plugin-market: Plugin Market
plugin-configs: Plugin Configs
@ -37,6 +39,7 @@ pause: Pause
reset: Reset
submit: Submit
op-success: Operated successfully.
notice: Notice
switch-2d-preview: Switch to 2D preview

View File

@ -47,6 +47,15 @@ rate:
addon: scores = 1 player
user_initial_score: User Initial Score
report:
title: Reporting Textures
reporter_score_modification:
title: Scores for Submitting an Report
description: Set a positive integer value to reward user who submits new reports. Set to a negative value will require scores for submitting reports, and the suspended scores will be available if user's report was resolved. Set to 0 to disable.
reporter_reward_score:
title: Reward the Reporter with Scores If Report Resolved
sign:
title: Signing
@ -125,6 +134,9 @@ general:
title: Texture Name Rules
hint: The RegExp for validating name of uploaded textures. Leave empty to allow any character except single, double quote and backslash.
placeholder: Regular Expressions
content_policy:
title: Content Policy
description: Display content policy at texture uploading page, supporting Markdown. To edit a specific language's corresponding content policy, please switch to that language and submit your edit.
comment_script:
title: Comment Script
description: Placeholder is available, <code>{tid}</code> will be replaced with texture id, <code>{name}</code> will be replaced with texture name, <code>{url}</code> will be replaced with current URL.

View File

@ -94,3 +94,7 @@ model:
no-permission: You have no permission to moderate this texture.
non-existent: No such texture.
report:
duplicate: You have already reported this texture. The administrators will review it as soon as possible. You can also track the status of your report at User Center.
success: Thanks for reporting! The administrators will review it as soon as possible.

View File

@ -151,4 +151,6 @@ update:
unzip: 更新包解压缩失败。错误代码:
overwrite: 你的服务器不支持自动更新:无法覆盖文件。
report-reviewed: 这一条举报已经处理过了。
invalid-action: 无效的操作名

View File

@ -71,6 +71,7 @@ skinlib:
emptyUploadFile: 你还没有上传任何文件哦
encodingError: 错误:这张图片编码不对哦
fileExtError: 错误:皮肤文件必须为 PNG 格式
showContentPolicy: 查看内容策略
uploading: 上传中
redirecting: 正在跳转...
setAsPrivate: 设为隐私
@ -100,9 +101,14 @@ skinlib:
size: 文件大小
uploader: 上传者
upload-at: 上传日期
download: 下载材质
download: 下载
delete-texture: 删除材质
manage-notice: 材质设为隐私或被删除后将会从每一个收藏者的衣柜中移除。
report:
title: 举报
reason: 请填写举报原因
positive: 为鼓励用户积极维护皮肤库的环境,每举报一个材质你可以获得 :score 积分的奖励。但是,如果被发现有恶意举报等行为,奖励的积分将会被全部收回,并且有可能受到额外的惩罚。
negative: 为了减轻恶意举报带来的工作量,我们会在你提交举报时扣除 :score 积分。不用担心,举报通过后暂扣的积分将会全部返还,并且可以获得额外的积分奖励。
user:
signRemainingTime: ':time :unit 后可签到'
@ -303,6 +309,21 @@ admin:
black: 高端黑
black-light: 黑色主题 - 白色侧边栏
report:
tid: 材质 ID
reporter: 举报人
reason: 举报原因
status-title: 状态
status:
- 正在处理
- 处理完成
- 已被拒绝
time: 举报时间
check: 查看
delete: 删除
ban: 封禁
reject: 拒绝举报
general:
skin: 皮肤
cape: 披风

View File

@ -12,8 +12,10 @@ anonymous: 未登录
back: 返回
dashboard: 仪表盘
my-closet: 我的衣柜
my-reports: 我的举报
player-manage: 角色管理
user-manage: 用户管理
report-manage: 举报管理
plugin-manage: 插件管理
plugin-market: 插件市场
plugin-configs: 插件配置
@ -37,6 +39,7 @@ pause: 暂停
reset: 重置
submit: 提交
op-success: 操作成功
notice: 提示
switch-2d-preview: 切换 2D 预览

View File

@ -47,6 +47,15 @@ rate:
addon: 积分 = 一个角色
user_initial_score: 新用户默认积分
report:
title: 材质举报
reporter_score_modification:
title: 提交举报所需积分
description: 举报材质时【奖励】或者【扣除】举报者一定的积分。设置为正数表示奖励相应积分,设置为负数时表示扣除相应积分,设置为 0 可关闭本功能。举报时扣除积分可以一定程度上减少恶意举报,如果举报通过后,扣除的积分将会被返还。
reporter_reward_score:
title: 举报通过后奖励积分
sign:
title: 签到配置
@ -125,6 +134,9 @@ general:
title: 材质名称规则
hint: 皮肤库上传材质时名称的正则表达式。留空表示允许使用除半角单双引号、反斜杠以外的任意字符。
placeholder: 正则表达式,不懂别乱填
content_policy:
title: 材质内容策略
description: 在材质上传页面将会显示此内容,支持 Markdown。如果想要编辑某种特定语言下的内容政策请在右上角切换至该语言后再提交修改。
comment_script:
title: 评论代码
description: 评论代码内可使用占位符,<code>{tid}</code> 将会被自动替换为材质的 id<code>{name}</code> 会被替换为材质名称,<code>{url}</code> 会被替换为当前页面地址。

View File

@ -93,3 +93,7 @@ model:
no-permission: 你没有权限修改此材质
non-existent: 材质不存在
report:
duplicate: 您已经举报过该材质了,请耐心等待管理员处理。您可以在用户中心查看举报的处理进度。
success: 举报已提交,请等待管理员处理

View File

@ -0,0 +1,12 @@
@extends('admin.master')
@section('title', trans('general.report-manage'))
@section('content')
<div class="content-wrapper">
<section class="content-header">
<h1>@lang('general.report-manage')</h1>
</section>
<section class="content"></section>
</div>
@endsection

View File

@ -18,6 +18,8 @@
<div class="row">
<div class="col-md-6">
{!! $forms['rate']->render() !!}
{!! $forms['report']->render() !!}
</div>
<div class="col-md-6">

View File

@ -0,0 +1,12 @@
@extends('user.master')
@section('title', trans('general.my-reports'))
@section('content')
<div class="content-wrapper">
<section class="content-header">
<h1>@lang('general.my-reports')</h1>
</section>
<section class="content"></section>
</div>
@endsection

View File

@ -49,6 +49,9 @@ Route::group([
Route::get('/score-info', 'UserController@scoreInfo');
Route::post('/sign', 'UserController@sign');
Route::get('/reports', 'ReportController@viewTrack');
Route::get('/report-list', 'ReportController@track');
// Profile
Route::get('/profile', 'UserController@profile');
Route::post('/profile', 'UserController@handleProfile');
@ -95,6 +98,7 @@ Route::group(['prefix' => 'skinlib'], function () {
Route::post('/rename', 'SkinlibController@rename');
Route::post('/privacy', 'SkinlibController@privacy');
Route::post('/delete', 'SkinlibController@delete');
Route::post('/report', 'ReportController@submit');
});
});
@ -111,15 +115,17 @@ Route::group(['middleware' => ['auth', 'admin'], 'prefix' => 'admin'], function
Route::any('/resource', 'AdminController@resource');
Route::view('/users', 'admin.users');
Route::post('/users', 'AdminController@userAjaxHandler');
Route::any('/user-data', 'AdminController@getUserData');
Route::view('/players', 'admin.players');
Route::post('/players', 'AdminController@playerAjaxHandler');
Route::any('/player-data', 'AdminController@getPlayerData');
Route::get('/user/{uid}', 'AdminController@getOneUser');
// ajax handlers
Route::post('/users', 'AdminController@userAjaxHandler');
Route::post('/players', 'AdminController@playerAjaxHandler');
Route::view('/reports', 'admin.reports');
Route::post('/reports', 'ReportController@review');
Route::any('/report-data', 'ReportController@manage');
Route::group(['prefix' => 'plugins', 'middleware' => 'super-admin'], function () {
Route::get('/data', 'PluginController@getPluginData');

View File

@ -87,6 +87,13 @@ class AdminControllerTest extends BrowserKitTestCase
$this->assertEquals('12', option('score_per_player'));
$this->assertEquals('500', option('user_initial_score'));
$this->visit('/admin/score')
->type('1', 'reporter_score_modification')
->type('2', 'reporter_reward_score')
->press('submit_report');
$this->assertEquals('1', option('reporter_score_modification'));
$this->assertEquals('2', option('reporter_reward_score'));
$this->visit('/admin/score')
->type('233', 'sign_score_from')
->type('666', 'sign_score_to')
@ -125,6 +132,7 @@ class AdminControllerTest extends BrowserKitTestCase
->type('/^([0-9]+)$/', 'custom_player_name_regexp')
->select('1', 'api_type')
->check('auto_del_invalid_texture')
->type('policy', 'content_policy')
->type('code', 'comment_script')
->press('submit_general');
$this->assertEquals('My Site', option_localized('site_name'));
@ -138,6 +146,7 @@ class AdminControllerTest extends BrowserKitTestCase
$this->assertEquals('/^([0-9]+)$/', option('custom_player_name_regexp'));
$this->assertEquals('1', option('api_type'));
$this->assertTrue(option('auto_del_invalid_texture'));
$this->assertEquals('policy', option_localized('content_policy'));
$this->assertEquals('code', option('comment_script'));
$this->visit('/admin/options')
@ -148,7 +157,7 @@ class AdminControllerTest extends BrowserKitTestCase
$this->visit('/admin/options')
->type('announcement', 'announcement')
->press('submit_announ');
$this->assertEquals('announcement', option('announcement'));
$this->assertEquals('announcement', option_localized('announcement'));
$this->visit('/admin/options')
->type('kw', 'meta_keywords')

View File

@ -0,0 +1,258 @@
<?php
namespace Tests;
use App\Models\User;
use App\Models\Report;
use App\Models\Texture;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ReportControllerTest extends TestCase
{
use RefreshDatabase;
public function testSubmit()
{
$user = factory(User::class)->create();
$texture = factory(Texture::class)->create();
// Without `tid` field
$this->actAs($user)
->postJson('/skinlib/report')
->assertJson([
'errno' => 1,
'msg' => trans('validation.required', ['attribute' => 'tid'])
]);
// Invalid texture
$this->postJson('/skinlib/report', ['tid' => $texture->tid - 1])
->assertJson([
'errno' => 1,
'msg' => trans('validation.exists', ['attribute' => 'tid'])
]);
// Without `reason` field
$this->postJson('/skinlib/report', ['tid' => $texture->tid])
->assertJson([
'errno' => 1,
'msg' => trans('validation.required', ['attribute' => 'reason'])
]);
// Lack of score
$user->score = 0;
$user->save();
option(['reporter_score_modification' => -5]);
$this->postJson('/skinlib/report', ['tid' => $texture->tid, 'reason' => 'reason'])
->assertJson([
'errno' => 1,
'msg' => trans('skinlib.upload.lack-score')
]);
// Success
option(['reporter_score_modification' => 5]);
$this->postJson('/skinlib/report', ['tid' => $texture->tid, 'reason' => 'reason'])
->assertJson([
'errno' => 0,
'msg' => trans('skinlib.report.success')
]);
$user->refresh();
$this->assertEquals(5, $user->score);
$report = Report::where('reporter', $user->uid)->first();
$this->assertEquals($texture->tid, $report->tid);
$this->assertEquals($texture->uploader, $report->uploader);
$this->assertEquals('reason', $report->reason);
$this->assertEquals(Report::PENDING, $report->status);
// Prevent duplication
$this->postJson('/skinlib/report', ['tid' => $texture->tid, 'reason' => 'reason'])
->assertJson([
'errno' => 1,
'msg' => trans('skinlib.report.duplicate')
]);
}
public function testViewTrack()
{
$user = factory(User::class)->create();
$this->actAs($user)->get('/user/reports')->assertViewIs('user.report');
}
public function testTrack()
{
$user = factory(User::class)->create();
$report = new Report;
$report->tid = 1;
$report->uploader = 0;
$report->reporter = $user->uid;
$report->reason = 'test';
$report->status = Report::PENDING;
$report->save();
$this->actAs($user)
->getJson('/user/report-list')
->assertJson([[
'tid' => 1,
'reason' => 'test',
'status' => Report::PENDING,
]]);
}
public function testManage()
{
$uploader = factory(User::class)->create();
$reporter = factory(User::class, 'admin')->create();
$texture = factory(Texture::class)->create(['uploader' => $uploader->uid]);
$report = new Report;
$report->tid = $texture->tid;
$report->uploader = $uploader->uid;
$report->reporter = $reporter->uid;
$report->reason = 'test';
$report->status = Report::PENDING;
$report->save();
$this->actAs($reporter)
->getJson('/admin/report-data')
->assertJson([
'totalRecords' => 1,
'data' => [[
'tid' => $texture->tid,
'uploader' => $uploader->uid,
'reporter' => $reporter->uid,
'reason' => 'test',
'status' => Report::PENDING,
'uploaderName' => $uploader->nickname,
'reporterName' => $reporter->nickname,
]]
]);
}
public function testReview()
{
$uploader = factory(User::class)->create();
$reporter = factory(User::class, 'admin')->create();
$texture = factory(Texture::class)->create(['uploader' => $uploader->uid]);
$report = new Report;
$report->tid = $texture->tid;
$report->uploader = $uploader->uid;
$report->reporter = $reporter->uid;
$report->reason = 'test';
$report->status = Report::REJECTED;
$report->save();
$report->refresh();
// Without `id` field
$this->actAs($reporter)
->postJson('/admin/reports')
->assertJson([
'errno' => 1,
'msg' => trans('validation.required', ['attribute' => 'id'])
]);
// Not existed
$this->postJson('/admin/reports', ['id' => $report->id - 1])
->assertJson([
'errno' => 1,
'msg' => trans('validation.exists', ['attribute' => 'id'])
]);
// Without `action` field
$this->postJson('/admin/reports', ['id' => $report->id])
->assertJson([
'errno' => 1,
'msg' => trans('validation.required', ['attribute' => 'action'])
]);
// Invalid action
$this->postJson('/admin/reports', ['id' => $report->id, 'action' => 'a'])
->assertJson([
'errno' => 1,
'msg' => trans('validation.in', ['attribute' => 'action'])
]);
// Only process pending report
$this->postJson('/admin/reports', ['id' => $report->id, 'action' => 'reject'])
->assertJson([
'errno' => 1,
'msg' => trans('admin.report-reviewed')
]);
// Reject
$report->status = Report::PENDING;
$report->save();
$score = $reporter->score;
$this->postJson('/admin/reports', ['id' => $report->id, 'action' => 'reject'])
->assertJson([
'errno' => 0,
'msg' => trans('general.op-success'),
'status' => Report::REJECTED
]);
$report->refresh();
$reporter->refresh();
$this->assertEquals(Report::REJECTED, $report->status);
$this->assertEquals($score, $reporter->score);
$report->refresh();
$report->status = Report::PENDING;
$report->save();
option(['reporter_score_modification' => 5]);
$score = $reporter->score;
$this->postJson('/admin/reports', ['id' => $report->id, 'action' => 'reject'])
->assertJson(['errno' => 0]);
$reporter->refresh();
$this->assertEquals($score - 5, $reporter->score);
// Delete texture
option(['reporter_score_modification' => -7]);
$report->refresh();
$report->status = Report::PENDING;
$report->save();
$score = $reporter->score;
$this->postJson('/admin/reports', ['id' => $report->id, 'action' => 'delete'])
->assertJson([
'errno' => 0,
'msg' => trans('general.op-success'),
'status' => Report::RESOLVED
]);
$report->refresh();
$reporter->refresh();
$this->assertEquals(Report::RESOLVED, $report->status);
$this->assertNull(Texture::find($texture->tid));
$this->assertEquals($score + 7, $reporter->score);
option(['reporter_score_modification' => 0]);
// Ban uploader
option(['reporter_reward_score' => 6]);
$report->refresh();
$report->status = Report::PENDING;
$report->reporter = $uploader->uid; // I REPORT MYSELF. (我 举 报 我 自 己)
$report->save();
$reporter = $uploader;
$score = $reporter->score;
$this->postJson('/admin/reports', ['id' => $report->id, 'action' => 'ban'])
->assertJson([
'errno' => 0,
'msg' => trans('general.op-success'),
'status' => Report::RESOLVED
]);
$reporter->refresh();
$this->assertEquals(User::BANNED, $uploader->permission);
$this->assertEquals($score + 6, $reporter->score);
option(['reporter_reward_score' => 0]);
$report->refresh();
$report->status = Report::PENDING;
$report->save();
$uploader->refresh();
$uploader->permission = User::ADMIN;
$uploader->save();
$this->postJson('/admin/reports', ['id' => $report->id, 'action' => 'ban'])
->assertJson([
'errno' => 1,
'msg' => trans('admin.users.operations.no-permission')
]);
$report->refresh();
$this->assertEquals(Report::PENDING, $report->status);
}
}

View File

@ -34,7 +34,13 @@ class SetupControllerTest extends TestCase
protected function dropAllTables()
{
$tables = [
'user_closet', 'migrations', 'options', 'players', 'textures', 'users',
'user_closet',
'migrations',
'options',
'players',
'textures',
'users',
'reports',
];
array_walk($tables, function ($table) {
Schema::dropIfExists($table);