mirror of
https://github.com/axeloz/filesharing.git
synced 2025-05-06 01:53:55 +02:00
Adding login/password authentication
This commit is contained in:
parent
9a03d54fc1
commit
879242cad8
19 changed files with 340 additions and 36 deletions
74
app/Console/Commands/CreateUser.php
Normal file
74
app/Console/Commands/CreateUser.php
Normal file
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CreateUser extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'fs:create-user {login?}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Command description';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$login = $this->argument('login');
|
||||
|
||||
login:
|
||||
// If user was not provided, asking for it
|
||||
if (empty($login)) {
|
||||
$login = $this->ask('Enter the user\'s login');
|
||||
}
|
||||
|
||||
if (! preg_match('~^[a-z0-9]{1,40}$~', $login)) {
|
||||
$this->error('Invalid login format. Must only contains letters and numbers, between 1 and 40 chars');
|
||||
unset($login);
|
||||
goto login;
|
||||
}
|
||||
|
||||
// Checking login unicity
|
||||
if (Storage::disk('users')->exists($login.'.json')) {
|
||||
$this->error('User "'.$login.'" already exists');
|
||||
unset($login);
|
||||
goto login;
|
||||
}
|
||||
|
||||
password:
|
||||
// Asking for user's password
|
||||
$password = $this->secret('Enter the user\'s password');
|
||||
|
||||
if (strlen($password) < 5) {
|
||||
$this->error('Invalid password format. Must only contains 5 chars minimum');
|
||||
unset($password);
|
||||
goto password;
|
||||
}
|
||||
|
||||
try {
|
||||
Storage::disk('users')->put($login.'.json', json_encode([
|
||||
'login' => $login,
|
||||
'password' => Hash::make($password)
|
||||
]));
|
||||
|
||||
$this->info('User has been created');
|
||||
}
|
||||
catch(Exception $e) {
|
||||
$this->error('An error occurred, could not create user');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ class PurgeFiles extends Command
|
|||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'storage:purge-expired';
|
||||
protected $signature = 'fs:purge-expired';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
|
|
|
@ -13,6 +13,7 @@ class Kernel extends ConsoleKernel
|
|||
protected function schedule(Schedule $schedule): void
|
||||
{
|
||||
// $schedule->command('inspire')->hourly();
|
||||
$schedule->command('fs:purge-expired')->hourly();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -53,8 +53,8 @@ class UploadController extends Controller
|
|||
$metadata = Upload::getMetadata($bundleId);
|
||||
|
||||
// Validating file
|
||||
abort_if(! $request->hasFile('file'), 401);
|
||||
abort_if(! $request->file('file')->isValid(), 401);
|
||||
abort_if(! $request->hasFile('file'), 422);
|
||||
abort_if(! $request->file('file')->isValid(), 422);
|
||||
|
||||
$this->validate($request, [
|
||||
'file' => 'required|file|max:'.(Upload::fileMaxSize() / 1000)
|
||||
|
@ -101,7 +101,7 @@ class UploadController extends Controller
|
|||
|
||||
$metadata = Upload::getMetadata($bundleId);
|
||||
|
||||
abort_if(empty($request->file), 401);
|
||||
abort_if(empty($request->file), 422);
|
||||
|
||||
try {
|
||||
$metadata = Upload::deleteFile($bundleId, $request->file);
|
||||
|
|
|
@ -2,19 +2,64 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
use App\Helpers\Upload;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class WebController extends Controller
|
||||
{
|
||||
function homepage(Request $request)
|
||||
public function homepage()
|
||||
{
|
||||
return view('homepage');
|
||||
}
|
||||
|
||||
public function login() {
|
||||
return view('login');
|
||||
}
|
||||
|
||||
public function doLogin(Request $request) {
|
||||
abort_if(! $request->ajax(), 403);
|
||||
|
||||
$request->validate([
|
||||
'login' => 'required',
|
||||
'password' => 'required'
|
||||
]);
|
||||
|
||||
try {
|
||||
if (Storage::disk('users')->missing($request->login.'.json')) {
|
||||
throw new Exception('Authentication failed');
|
||||
}
|
||||
|
||||
$json = Storage::disk('users')->get($request->login.'.json');
|
||||
|
||||
if (! $user = json_decode($json, true)) {
|
||||
throw new Exception('Cannot decode JSON file');
|
||||
}
|
||||
|
||||
if (! Hash::check($request->password, $user['password'])) {
|
||||
throw new Exception('Authentication failed');
|
||||
}
|
||||
|
||||
$request->session()->put('login', $request->login);
|
||||
$request->session()->put('authenticated', true);
|
||||
|
||||
return response()->json([
|
||||
'result' => true,
|
||||
]);
|
||||
}
|
||||
catch (Exception $e) {
|
||||
return response()->json([
|
||||
'result' => false,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function newBundle(Request $request) {
|
||||
// Aborting if request is not AJAX
|
||||
abort_if(! $request->ajax(), 401);
|
||||
abort_if(! $request->ajax(), 403);
|
||||
|
||||
$request->validate([
|
||||
'bundle_id' => 'required',
|
||||
|
|
|
@ -63,7 +63,7 @@ class Kernel extends HttpKernel
|
|||
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
||||
'upload' => \App\Http\Middleware\UploadAccess::class,
|
||||
'can.upload' => \App\Http\Middleware\UploadAccess::class,
|
||||
'access.owner' => \App\Http\Middleware\OwnerAccess::class,
|
||||
'access.guest' => \App\Http\Middleware\GuestAccess::class
|
||||
];
|
||||
|
|
|
@ -17,9 +17,9 @@ class GuestAccess
|
|||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Aborting if Bundle ID is not present
|
||||
abort_if(empty($request->route()->parameter('bundle')), 401);
|
||||
abort_if(empty($request->route()->parameter('bundle')), 403);
|
||||
|
||||
abort_if(empty($request->auth), 401);
|
||||
abort_if(empty($request->auth), 403);
|
||||
|
||||
// Getting metadata
|
||||
$metadata = Upload::getMetadata($request->route()->parameter('bundle'));
|
||||
|
@ -28,7 +28,7 @@ class GuestAccess
|
|||
abort_if(empty($metadata), 404);
|
||||
|
||||
// Aborting if auth_token is different from URL param
|
||||
abort_if($metadata['preview_token'] !== $request->auth, 401);
|
||||
abort_if($metadata['preview_token'] !== $request->auth, 403);
|
||||
|
||||
// Checking bundle expiration
|
||||
abort_if($metadata['expires_at'] < time(), 404);
|
||||
|
|
|
@ -17,10 +17,10 @@ class OwnerAccess
|
|||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Aborting if request is not AJAX
|
||||
abort_if(! $request->ajax(), 401);
|
||||
abort_if(! $request->ajax(), 403);
|
||||
|
||||
// Aborting if Bundle ID is not present
|
||||
abort_if(empty($request->route()->parameter('bundle')), 401);
|
||||
abort_if(empty($request->route()->parameter('bundle')), 403);
|
||||
|
||||
// Aborting if auth is not present
|
||||
$auth = null;
|
||||
|
@ -30,7 +30,7 @@ class OwnerAccess
|
|||
else if (! empty($request->auth)) {
|
||||
$auth = $request->auth;
|
||||
}
|
||||
abort_if(empty($auth), 401);
|
||||
abort_if(empty($auth), 403);
|
||||
|
||||
// Getting metadata
|
||||
$metadata = Upload::getMetadata($request->route()->parameter('bundle'));
|
||||
|
@ -39,7 +39,7 @@ class OwnerAccess
|
|||
abort_if(empty($metadata), 404);
|
||||
|
||||
// Aborting if auth_token is different from URL param
|
||||
abort_if($metadata['owner_token'] !== $auth, 401);
|
||||
abort_if($metadata['owner_token'] !== $auth, 403);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
|
|
@ -18,8 +18,14 @@ class UploadAccess
|
|||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Upload::canUpload($request->ip()) !== true) {
|
||||
abort(401);
|
||||
if ($request->session()->missing('authenticated') && Upload::canUpload($request->ip()) !== true) {
|
||||
//return redirect('login');
|
||||
if ($request->ajax()) {
|
||||
abort(401);
|
||||
}
|
||||
else {
|
||||
return response()->view('login');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
|
|
|
@ -60,6 +60,22 @@ return [
|
|||
]
|
||||
],
|
||||
|
||||
'users' => [
|
||||
'driver' => 'local',
|
||||
'root' => env('STORAGE_PATH', storage_path('app/users')),
|
||||
'visibility' => 'private',
|
||||
'permissions' => [
|
||||
'file' => [
|
||||
'public' => 0600,
|
||||
'private' => 0600,
|
||||
],
|
||||
'dir' => [
|
||||
'public' => 0755,
|
||||
'private' => 0700,
|
||||
],
|
||||
]
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
|
|
|
@ -75,7 +75,11 @@ return [
|
|||
'expired' => 'Expired',
|
||||
'existing-bundles' => 'Your existing bundles',
|
||||
'or-create' => 'New bundle',
|
||||
'no-existing-bundle' => 'No existing bundle'
|
||||
'no-existing-bundle' => 'No existing bundle',
|
||||
'authentication' => 'Authentication',
|
||||
'login' => 'Username',
|
||||
'password' => 'Password',
|
||||
'do-login' => 'Login now'
|
||||
|
||||
|
||||
];
|
||||
|
|
|
@ -75,7 +75,11 @@ return [
|
|||
'expired' => 'Expirés',
|
||||
'existing-bundles' => 'Vos archives existantes',
|
||||
'or-create' => 'Nouvelle archive',
|
||||
'no-existing-bundle' => 'Aucune archive existante'
|
||||
'no-existing-bundle' => 'Aucune archive existante',
|
||||
'authentication' => 'Authentification',
|
||||
'login' => 'Identifiant',
|
||||
'password' => 'Mot de passe',
|
||||
'do-login' => 'S\'authentifier'
|
||||
|
||||
|
||||
];
|
||||
|
|
38
readme.md
38
readme.md
|
@ -1,37 +1,32 @@
|
|||
# Files Sharing
|
||||
|
||||
> !!!
|
||||
>
|
||||
> FILES SHARING VERSION 2 JUST RELEASED
|
||||
> !!!
|
||||
>
|
||||
|
||||
<p align="center"><img src="https://github.com/axeloz/filesharing/raw/main/public/images/capture.gif" width="700" /></p>
|
||||
|
||||
Powered by Laravel
|
||||
Powered by
|
||||
<p><img src="https://laravel.com/assets/img/components/logo-laravel.svg"></p>
|
||||
|
||||
## Description
|
||||
|
||||
This PHP application based on Laravel 10.9 allows to share files like Wetransfer. You may install it **on your own server**. It **does not require** any database system, it works with JSON files into the storage folder. It is **multilingual** and comes with english and french translations for now. You're welcome to help translating the app.
|
||||
|
||||
It comes with a droplet. You may drag and drop some files or directories into the droplet, your files will be uploaded to the server as a bundle.
|
||||
|
||||
A bundle is like a package containing a various number of files. You may choose the expiration date of the bundle.
|
||||
|
||||
This application provides two links per bundle :
|
||||
- a bundle preview link : you can send this link to your recipients who will see the bundle content. For example: http://yourdomain/bundle/dda2d646b6746b96ea9b?auth=965242. The recipient can see all the files of the bundle, can download one given file only or the entire bundle.
|
||||
- a bundle download link : you can send this link yo your recipients who will download all the files of the bundle at once (without any preview). For example: http://yourdomain/bundle/dda2d646b6746b96ea9b/download?auth=965242.
|
||||
|
||||
Each of these links comes with an authorization code. This code is the same for the preview and the download links. However it is different for the deletion link for obvious reasons.
|
||||
Each of these links comes with an authorization code. This code is the same for the preview and the download links.
|
||||
|
||||
The application also comes with a Laravel Artisan command as a background task who will physically remove expired bundle files of the storage disk. This command is configured to run every five minutes among the Laravel scheduled commands.
|
||||
|
||||
Sorry about the design, I'm not very good at this, you're welcome to help and participate.
|
||||
|
||||
## Features
|
||||
|
||||
- uploader access permission: IP based or login/password
|
||||
- creation of a new bundle
|
||||
- define settings : title, description, expiration date, number max of downloads, password...
|
||||
- upload one or more files via drag and drop or via browsing your computer
|
||||
- upload one or more files via drag and drop or via browsing your filesystem
|
||||
- ability to keep adding files to the bundle days later
|
||||
- sharing link with bundle content preview
|
||||
- ability to download the entire bundle as ZIP archive (password protected when applicable)
|
||||
|
@ -39,7 +34,6 @@ Sorry about the design, I'm not very good at this, you're welcome to help and pa
|
|||
- garbage collector which removes the expired bundles as a background task
|
||||
- multilingual (EN and FR)
|
||||
- easy installation, **no database required**
|
||||
- upload limitation based on client IP filtering
|
||||
- secured by tokens, authentication codes and non-publicly-accessible files
|
||||
|
||||
## Requirements
|
||||
|
@ -76,7 +70,9 @@ The application also uses:
|
|||
- make sure that the PHP process has write permission on the `./storage` folder
|
||||
- generate the Laravel KEY: `php artisan key:generate`
|
||||
- run `cp .env.example .env` and edit `.env` to fit your needs
|
||||
- start the Laravel scheduler (it will delete expired bundles of the storage). For example `0 * * * * /usr/bin/php /path-to-your-project/artisan schedule:run >> /dev/null 2>&1`
|
||||
- (optional) you may create your first user `php artisan fs:create-user`
|
||||
- start the Laravel scheduler (it will delete expired bundles of the storage). For example `* * * * * /usr/bin/php /path-to-your-project/artisan schedule:run >> /dev/null 2>&1`
|
||||
- (optional) to purge bundles manually, run `php artisan fs:purge-expired`
|
||||
|
||||
|
||||
Use your browser to navigate to your domain name (example: files.yourdomain.com) and **that's it**.
|
||||
|
@ -96,6 +92,21 @@ In order to configure your application, copy the .env.example file into .env. Th
|
|||
| `UPLOAD_LIMIT_IPS` | (*optional*) a comma separated list of IPs from which you may upload files. Different formats are supported : Full IP address (192.168.10.2), Wildcard format (192.168.10.*), CIDR Format (192.168.10/24 or 1.2.3.4/255.255.255.0) or Start-end IP (192.168.10.0-192.168.10.10). When missing, filtering is disabled. |
|
||||
| `APP_NAME` | the title of the application |
|
||||
|
||||
|
||||
## Authentication
|
||||
|
||||
You may provide a list of IPs to limit access to the upload feature.
|
||||
Or you can create users with login/password credentials.
|
||||
You can also **mix the two methods**.
|
||||
|
||||
>
|
||||
> Warning: if your leave the `UPLOAD_LIMIT_IPS` empty and you don't create users, the upload will be publicly accessible
|
||||
>
|
||||
|
||||
## Known issues
|
||||
|
||||
If you are using Nginx, you might be required to do additional setup in order to increase the upload max size. Check the Nginx's documentation for `client_max_body_size`.
|
||||
|
||||
## Development
|
||||
|
||||
If your want to modify the sources, you can use the Laravel Mix features:
|
||||
|
@ -109,7 +120,6 @@ If your want to modify the sources, you can use the Laravel Mix features:
|
|||
## Roadmap / Ideas / Improvements
|
||||
|
||||
There are many ideas to come. You are welcome to **participate**.
|
||||
- limit upload permission by a password (or passwords)
|
||||
- add PHP unit testing
|
||||
- more testing on heavy files
|
||||
- customizable / white labeling (logo, name, terms of service, footer ...)
|
||||
|
|
|
@ -12,6 +12,19 @@ moment().format();
|
|||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
window.axios.interceptors.response.use(function (response) {
|
||||
return response;
|
||||
}, function (error) {
|
||||
|
||||
// Authenticated user
|
||||
if (error.response.status == 401) {
|
||||
window.location.href = '/'
|
||||
}
|
||||
else {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
import Dropzone from "dropzone";
|
||||
window.Dropzone = Dropzone;
|
||||
import "dropzone/dist/dropzone.css";
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
</div>
|
||||
|
||||
<div class="my-10 text-center text-base font-title uppercase text-primary">
|
||||
<h1 class="text-7xl mb-0 font-black">401</h1>
|
||||
<h1 class="text-7xl mb-0 font-black">403</h1>
|
||||
@lang('app.permission-denied')
|
||||
</div>
|
||||
</div>
|
|
@ -110,7 +110,10 @@
|
|||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<h2 class="font-title text-2xl mb-5 text-primary font-medium uppercase">@lang('app.existing-bundles')</h2>
|
||||
<h2 class="font-title text-2xl mb-5 text-primary font-medium uppercase flex items-center">
|
||||
<p>@lang('app.existing-bundles')</p>
|
||||
<p class="text-sm bg-primary rounded-full ml-2 text-white px-3" x-text="Object.keys(bundles).length"></p>
|
||||
</h2>
|
||||
|
||||
<span x-show="bundles == null || Object.keys(bundles).length == 0">@lang('app.no-existing-bundle')</span>
|
||||
<select
|
||||
|
|
124
resources/views/login.blade.php
Normal file
124
resources/views/login.blade.php
Normal file
|
@ -0,0 +1,124 @@
|
|||
@extends('layout')
|
||||
|
||||
@section('page_title', __('app.authentication'))
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('login', () => ({
|
||||
user: {
|
||||
login: null,
|
||||
password: null
|
||||
},
|
||||
error: null,
|
||||
|
||||
loginUser: function() {
|
||||
errors = false
|
||||
document.getElementById('user-login').setCustomValidity('')
|
||||
document.getElementById('user-password').setCustomValidity('')
|
||||
|
||||
if (this.user.login == null || this.user.login == '') {
|
||||
document.getElementById('user-login').setCustomValidity('Field is required')
|
||||
errors = true
|
||||
}
|
||||
|
||||
if (this.user.password == null || this.user.password == '') {
|
||||
document.getElementById('user-password').setCustomValidity('Field is required')
|
||||
errors = true
|
||||
}
|
||||
|
||||
if (errors === true) {
|
||||
return false
|
||||
}
|
||||
|
||||
axios({
|
||||
url: '/login',
|
||||
method: 'POST',
|
||||
data: {
|
||||
login: this.user.login,
|
||||
password: this.user.password
|
||||
}
|
||||
})
|
||||
.then( (response) => {
|
||||
if (response.data.result == true) {
|
||||
window.location.href = '/'
|
||||
}
|
||||
else {
|
||||
this.error = response.data.error
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div x-data="login">
|
||||
<div class="relative bg-white border border-primary rounded-lg overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-primary-light to-primary px-2 py-4 text-center">
|
||||
<h1 class="relative font-title font-medium font-body text-4xl text-center text-white uppercase flex items-center">
|
||||
|
||||
<div class="grow text-center">{{ config('app.name') }}</div>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<h2 class="font-title text-2xl mb-5 text-primary font-medium uppercase flex items-center">
|
||||
<p>@lang('app.authentication')</p>
|
||||
</h2>
|
||||
|
||||
<template x-if="error">
|
||||
<div class="w-full my-3 rounded px-3 py-2 bg-red-100 text-red-600" x-text="error"></div>
|
||||
</template>
|
||||
|
||||
|
||||
{{-- Login --}}
|
||||
<div class="">
|
||||
<p class="font-title uppercase">
|
||||
@lang('app.login')
|
||||
<span class="text-base">*</span>
|
||||
</p>
|
||||
|
||||
<input
|
||||
x-model="user.login"
|
||||
class="w-full p-0 bg-transparent text-slate-700 h-8 py-1 rounded-none border-b border-purple-300 outline-none invalid:border-b-red-500 invalid:bg-red-50"
|
||||
type="text"
|
||||
name="login"
|
||||
id="user-login"
|
||||
maxlength="40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{-- Password --}}
|
||||
<div class="mt-5">
|
||||
<p class="font-title uppercase">
|
||||
@lang('app.password')
|
||||
<span class="text-base">*</span>
|
||||
</p>
|
||||
|
||||
<input
|
||||
x-model="user.password"
|
||||
class="w-full p-0 bg-transparent text-slate-700 h-8 py-1 rounded-none border-b border-purple-300 outline-none invalid:border-b-red-500 invalid:bg-red-50"
|
||||
type="password"
|
||||
name="password"
|
||||
id="user-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{-- Buttons --}}
|
||||
<div class="grid grid-cols-2 gap-10 mt-10 text-center">
|
||||
<div> </div>
|
||||
<div>
|
||||
@include('partials.button', [
|
||||
'way' => 'right',
|
||||
'text' => __('app.do-login'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />',
|
||||
'action' => 'loginUser'
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
|
@ -1,5 +1,7 @@
|
|||
@extends('layout')
|
||||
|
||||
@section('page_title', __('app.upload'))
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
let baseUrl = @js($baseUrl);
|
||||
|
|
|
@ -18,8 +18,10 @@ use App\Http\Middleware\UploadAccess;
|
|||
|
|
||||
*/
|
||||
|
||||
Route::get('/login', [WebController::class, 'login'])->name('login');
|
||||
Route::post('/login', [WebController::class, 'doLogin'])->name('login');
|
||||
|
||||
Route::middleware(['upload'])->group(function() {
|
||||
Route::middleware(['can.upload'])->group(function() {
|
||||
Route::get('/', [WebController::class, 'homepage'])->name('homepage');
|
||||
Route::post('/new', [WebController::class, 'newBundle'])->name('bundle.new');
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue