mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Started work on attachments
Created base models and started user-facing controls.
This commit is contained in:
parent
3b7d223b0c
commit
673c74ddfc
4
app/Exceptions/FileUploadException.php
Normal file
4
app/Exceptions/FileUploadException.php
Normal file
@ -0,0 +1,4 @@
|
||||
<?php namespace BookStack\Exceptions;
|
||||
|
||||
|
||||
class FileUploadException extends PrettyException {}
|
18
app/File.php
Normal file
18
app/File.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php namespace BookStack;
|
||||
|
||||
|
||||
class File extends Ownable
|
||||
{
|
||||
protected $fillable = ['name', 'order'];
|
||||
|
||||
/**
|
||||
* Get the page this file was uploaded to.
|
||||
* @return mixed
|
||||
*/
|
||||
public function page()
|
||||
{
|
||||
return $this->belongsTo(Page::class, 'uploaded_to');
|
||||
}
|
||||
|
||||
|
||||
}
|
91
app/Http/Controllers/FileController.php
Normal file
91
app/Http/Controllers/FileController.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use BookStack\File;
|
||||
use BookStack\Page;
|
||||
use BookStack\Repos\PageRepo;
|
||||
use BookStack\Services\FileService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
use BookStack\Http\Requests;
|
||||
|
||||
class FileController extends Controller
|
||||
{
|
||||
protected $fileService;
|
||||
protected $file;
|
||||
protected $pageRepo;
|
||||
|
||||
/**
|
||||
* FileController constructor.
|
||||
* @param FileService $fileService
|
||||
* @param File $file
|
||||
* @param PageRepo $pageRepo
|
||||
*/
|
||||
public function __construct(FileService $fileService, File $file, PageRepo $pageRepo)
|
||||
{
|
||||
$this->fileService = $fileService;
|
||||
$this->file = $file;
|
||||
$this->pageRepo = $pageRepo;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Endpoint at which files are uploaded to.
|
||||
* @param Request $request
|
||||
*/
|
||||
public function upload(Request $request)
|
||||
{
|
||||
// TODO - Add file upload permission check
|
||||
// TODO - ensure user has permission to edit relevant page.
|
||||
// TODO - ensure uploads are deleted on page delete.
|
||||
|
||||
$this->validate($request, [
|
||||
'uploaded_to' => 'required|integer|exists:pages,id'
|
||||
]);
|
||||
|
||||
$uploadedFile = $request->file('file');
|
||||
$pageId = $request->get('uploaded_to');
|
||||
|
||||
try {
|
||||
$file = $this->fileService->saveNewUpload($uploadedFile, $pageId);
|
||||
} catch (FileUploadException $e) {
|
||||
return response($e->getMessage(), 500);
|
||||
}
|
||||
|
||||
return response()->json($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the files for a specific page.
|
||||
* @param $pageId
|
||||
* @return mixed
|
||||
*/
|
||||
public function getFilesForPage($pageId)
|
||||
{
|
||||
// TODO - check view permission on page?
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
return response()->json($page->files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the file sorting.
|
||||
* @param $pageId
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function sortFilesForPage($pageId, Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'files' => 'required|array',
|
||||
'files.*.id' => 'required|integer',
|
||||
]);
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$files = $request->get('files');
|
||||
$this->fileService->updateFileOrderWithinPage($files, $pageId);
|
||||
return response()->json(['message' => 'File order updated']);
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -54,6 +54,15 @@ class Page extends Entity
|
||||
return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the files attached to this page.
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function files()
|
||||
{
|
||||
return $this->hasMany(File::class, 'uploaded_to')->orderBy('order', 'asc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the url for this page.
|
||||
* @param string|bool $path
|
||||
|
@ -48,7 +48,7 @@ class PageRepo extends EntityRepo
|
||||
* Get a page via a specific ID.
|
||||
* @param $id
|
||||
* @param bool $allowDrafts
|
||||
* @return mixed
|
||||
* @return Page
|
||||
*/
|
||||
public function getById($id, $allowDrafts = false)
|
||||
{
|
||||
|
79
app/Services/FileService.php
Normal file
79
app/Services/FileService.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php namespace BookStack\Services;
|
||||
|
||||
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use BookStack\File;
|
||||
use Exception;
|
||||
use Illuminate\Support\Collection;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class FileService extends UploadService
|
||||
{
|
||||
|
||||
/**
|
||||
* Store a new file upon user upload.
|
||||
* @param UploadedFile $uploadedFile
|
||||
* @param int $page_id
|
||||
* @return File
|
||||
* @throws FileUploadException
|
||||
*/
|
||||
public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
|
||||
{
|
||||
$fileName = $uploadedFile->getClientOriginalName();
|
||||
$fileData = file_get_contents($uploadedFile->getRealPath());
|
||||
|
||||
$storage = $this->getStorage();
|
||||
$fileBasePath = 'uploads/files/' . Date('Y-m-M') . '/';
|
||||
$storageBasePath = $this->getStorageBasePath() . $fileBasePath;
|
||||
|
||||
$uploadFileName = $fileName;
|
||||
while ($storage->exists($storageBasePath . $uploadFileName)) {
|
||||
$uploadFileName = str_random(3) . $uploadFileName;
|
||||
}
|
||||
|
||||
$filePath = $fileBasePath . $uploadFileName;
|
||||
$fileStoragePath = $this->getStorageBasePath() . $filePath;
|
||||
|
||||
try {
|
||||
$storage->put($fileStoragePath, $fileData);
|
||||
} catch (Exception $e) {
|
||||
throw new FileUploadException('File path ' . $fileStoragePath . ' could not be uploaded to. Ensure it is writable to the server.');
|
||||
}
|
||||
|
||||
$largestExistingOrder = File::where('uploaded_to', '=', $page_id)->max('order');
|
||||
|
||||
$file = File::forceCreate([
|
||||
'name' => $fileName,
|
||||
'path' => $filePath,
|
||||
'uploaded_to' => $page_id,
|
||||
'created_by' => user()->id,
|
||||
'updated_by' => user()->id,
|
||||
'order' => $largestExistingOrder + 1
|
||||
]);
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file storage base path, amended for storage type.
|
||||
* This allows us to keep a generic path in the database.
|
||||
* @return string
|
||||
*/
|
||||
private function getStorageBasePath()
|
||||
{
|
||||
return $this->isLocal() ? 'storage/' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the file ordering for a listing of attached files.
|
||||
* @param array $fileList
|
||||
* @param $pageId
|
||||
*/
|
||||
public function updateFileOrderWithinPage($fileList, $pageId)
|
||||
{
|
||||
foreach ($fileList as $index => $file) {
|
||||
File::where('uploaded_to', '=', $pageId)->where('id', '=', $file['id'])->update(['order' => $index]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -9,20 +9,13 @@ use Intervention\Image\ImageManager;
|
||||
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
|
||||
use Illuminate\Contracts\Cache\Repository as Cache;
|
||||
use Setting;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class ImageService
|
||||
class ImageService extends UploadService
|
||||
{
|
||||
|
||||
protected $imageTool;
|
||||
protected $fileSystem;
|
||||
protected $cache;
|
||||
|
||||
/**
|
||||
* @var FileSystemInstance
|
||||
*/
|
||||
protected $storageInstance;
|
||||
protected $storageUrl;
|
||||
|
||||
/**
|
||||
@ -34,8 +27,8 @@ class ImageService
|
||||
public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
|
||||
{
|
||||
$this->imageTool = $imageTool;
|
||||
$this->fileSystem = $fileSystem;
|
||||
$this->cache = $cache;
|
||||
parent::__construct($fileSystem);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -88,6 +81,9 @@ class ImageService
|
||||
if ($secureUploads) $imageName = str_random(16) . '-' . $imageName;
|
||||
|
||||
$imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/';
|
||||
|
||||
if ($this->isLocal()) $imagePath = '/public' . $imagePath;
|
||||
|
||||
while ($storage->exists($imagePath . $imageName)) {
|
||||
$imageName = str_random(3) . $imageName;
|
||||
}
|
||||
@ -100,6 +96,8 @@ class ImageService
|
||||
throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.');
|
||||
}
|
||||
|
||||
if ($this->isLocal()) $fullPath = str_replace_first('/public', '', $fullPath);
|
||||
|
||||
$imageDetails = [
|
||||
'name' => $imageName,
|
||||
'path' => $fullPath,
|
||||
@ -119,6 +117,16 @@ class ImageService
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage path, Dependant of storage type.
|
||||
* @param Image $image
|
||||
* @return mixed|string
|
||||
*/
|
||||
protected function getPath(Image $image)
|
||||
{
|
||||
return ($this->isLocal()) ? ('public/' . $image->path) : $image->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the thumbnail for an image.
|
||||
* If $keepRatio is true only the width will be used.
|
||||
@ -135,7 +143,8 @@ class ImageService
|
||||
public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
|
||||
{
|
||||
$thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
|
||||
$thumbFilePath = dirname($image->path) . $thumbDirName . basename($image->path);
|
||||
$imagePath = $this->getPath($image);
|
||||
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
|
||||
|
||||
if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
|
||||
return $this->getPublicUrl($thumbFilePath);
|
||||
@ -148,7 +157,7 @@ class ImageService
|
||||
}
|
||||
|
||||
try {
|
||||
$thumb = $this->imageTool->make($storage->get($image->path));
|
||||
$thumb = $this->imageTool->make($storage->get($imagePath));
|
||||
} catch (Exception $e) {
|
||||
if ($e instanceof \ErrorException || $e instanceof NotSupportedException) {
|
||||
throw new ImageUploadException('The server cannot create thumbnails. Please check you have the GD PHP extension installed.');
|
||||
@ -183,8 +192,8 @@ class ImageService
|
||||
{
|
||||
$storage = $this->getStorage();
|
||||
|
||||
$imageFolder = dirname($image->path);
|
||||
$imageFileName = basename($image->path);
|
||||
$imageFolder = dirname($this->getPath($image));
|
||||
$imageFileName = basename($this->getPath($image));
|
||||
$allImages = collect($storage->allFiles($imageFolder));
|
||||
|
||||
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
|
||||
@ -222,35 +231,9 @@ class ImageService
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage that will be used for storing images.
|
||||
* @return FileSystemInstance
|
||||
*/
|
||||
private function getStorage()
|
||||
{
|
||||
if ($this->storageInstance !== null) return $this->storageInstance;
|
||||
|
||||
$storageType = config('filesystems.default');
|
||||
$this->storageInstance = $this->fileSystem->disk($storageType);
|
||||
|
||||
return $this->storageInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not a folder is empty.
|
||||
* @param $path
|
||||
* @return int
|
||||
*/
|
||||
private function isFolderEmpty($path)
|
||||
{
|
||||
$files = $this->getStorage()->files($path);
|
||||
$folders = $this->getStorage()->directories($path);
|
||||
return count($files) === 0 && count($folders) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a public facing url for an image by checking relevant environment variables.
|
||||
* @param $filePath
|
||||
* @param string $filePath
|
||||
* @return string
|
||||
*/
|
||||
private function getPublicUrl($filePath)
|
||||
@ -273,6 +256,8 @@ class ImageService
|
||||
$this->storageUrl = $storageUrl;
|
||||
}
|
||||
|
||||
if ($this->isLocal()) $filePath = str_replace_first('public/', '', $filePath);
|
||||
|
||||
return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath;
|
||||
}
|
||||
|
||||
|
64
app/Services/UploadService.php
Normal file
64
app/Services/UploadService.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php namespace BookStack\Services;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
|
||||
|
||||
class UploadService
|
||||
{
|
||||
|
||||
/**
|
||||
* @var FileSystem
|
||||
*/
|
||||
protected $fileSystem;
|
||||
|
||||
/**
|
||||
* @var FileSystemInstance
|
||||
*/
|
||||
protected $storageInstance;
|
||||
|
||||
|
||||
/**
|
||||
* FileService constructor.
|
||||
* @param $fileSystem
|
||||
*/
|
||||
public function __construct(FileSystem $fileSystem)
|
||||
{
|
||||
$this->fileSystem = $fileSystem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage that will be used for storing images.
|
||||
* @return FileSystemInstance
|
||||
*/
|
||||
protected function getStorage()
|
||||
{
|
||||
if ($this->storageInstance !== null) return $this->storageInstance;
|
||||
|
||||
$storageType = config('filesystems.default');
|
||||
$this->storageInstance = $this->fileSystem->disk($storageType);
|
||||
|
||||
return $this->storageInstance;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check whether or not a folder is empty.
|
||||
* @param $path
|
||||
* @return bool
|
||||
*/
|
||||
protected function isFolderEmpty($path)
|
||||
{
|
||||
$files = $this->getStorage()->files($path);
|
||||
$folders = $this->getStorage()->directories($path);
|
||||
return (count($files) === 0 && count($folders) === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if using a local filesystem.
|
||||
* @return bool
|
||||
*/
|
||||
protected function isLocal()
|
||||
{
|
||||
return strtolower(config('filesystems.default')) === 'local';
|
||||
}
|
||||
}
|
@ -56,7 +56,7 @@ return [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => public_path(),
|
||||
'root' => base_path(),
|
||||
],
|
||||
|
||||
'ftp' => [
|
||||
|
42
database/migrations/2016_10_09_142037_create_files_table.php
Normal file
42
database/migrations/2016_10_09_142037_create_files_table.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class CreateFilesTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('files', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('path');
|
||||
$table->integer('uploaded_to');
|
||||
|
||||
$table->boolean('external');
|
||||
$table->integer('order');
|
||||
|
||||
$table->integer('created_by');
|
||||
$table->integer('updated_by');
|
||||
|
||||
$table->index('uploaded_to');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('files');
|
||||
}
|
||||
}
|
@ -460,7 +460,7 @@ module.exports = function (ngApp, events) {
|
||||
* Get all tags for the current book and add into scope.
|
||||
*/
|
||||
function getTags() {
|
||||
let url = window.baseUrl('/ajax/tags/get/page/' + pageId);
|
||||
let url = window.baseUrl(`/ajax/tags/get/page/${pageId}`);
|
||||
$http.get(url).then((responseData) => {
|
||||
$scope.tags = responseData.data;
|
||||
addEmptyTag();
|
||||
@ -529,6 +529,74 @@ module.exports = function (ngApp, events) {
|
||||
|
||||
}]);
|
||||
|
||||
|
||||
ngApp.controller('PageAttachmentController', ['$scope', '$http', '$attrs',
|
||||
function ($scope, $http, $attrs) {
|
||||
|
||||
const pageId = $scope.uploadedTo = $attrs.pageId;
|
||||
let currentOrder = '';
|
||||
$scope.files = [];
|
||||
|
||||
// Angular-UI-Sort options
|
||||
$scope.sortOptions = {
|
||||
handle: '.handle',
|
||||
items: '> tr',
|
||||
containment: "parent",
|
||||
axis: "y",
|
||||
stop: sortUpdate,
|
||||
};
|
||||
|
||||
/**
|
||||
* Event listener for sort changes.
|
||||
* Updates the file ordering on the server.
|
||||
* @param event
|
||||
* @param ui
|
||||
*/
|
||||
function sortUpdate(event, ui) {
|
||||
let newOrder = $scope.files.map(file => {return file.id}).join(':');
|
||||
if (newOrder === currentOrder) return;
|
||||
|
||||
currentOrder = newOrder;
|
||||
$http.put(`/files/sort/page/${pageId}`, {files: $scope.files}).then(resp => {
|
||||
events.emit('success', resp.data.message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by dropzone to get the endpoint to upload to.
|
||||
* @returns {string}
|
||||
*/
|
||||
$scope.getUploadUrl = function () {
|
||||
return window.baseUrl('/files/upload');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get files for the current page from the server.
|
||||
*/
|
||||
function getFiles() {
|
||||
let url = window.baseUrl(`/files/get/page/${pageId}`)
|
||||
$http.get(url).then(responseData => {
|
||||
$scope.files = responseData.data;
|
||||
currentOrder = responseData.data.map(file => {return file.id}).join(':');
|
||||
});
|
||||
}
|
||||
getFiles();
|
||||
|
||||
/**
|
||||
* Runs on file upload, Adds an file to local file list
|
||||
* and shows a success message to the user.
|
||||
* @param file
|
||||
* @param data
|
||||
*/
|
||||
$scope.uploadSuccess = function (file, data) {
|
||||
$scope.$apply(() => {
|
||||
$scope.files.unshift(data);
|
||||
});
|
||||
events.emit('success', 'File uploaded');
|
||||
};
|
||||
|
||||
}]);
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
@ -43,10 +43,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
//body.ie .popup-body {
|
||||
// min-height: 100%;
|
||||
//}
|
||||
|
||||
.corner-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@ -82,7 +78,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
#image-manager .dropzone-container {
|
||||
.dropzone-container {
|
||||
position: relative;
|
||||
border: 3px dashed #DDD;
|
||||
}
|
||||
|
@ -4,6 +4,7 @@
|
||||
<div class="tabs primary-background-light">
|
||||
<span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span>
|
||||
<span tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span>
|
||||
<span tab-button="files" title="Attachments"><i class="zmdi zmdi-attachment"></i></span>
|
||||
</div>
|
||||
|
||||
<div tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
|
||||
@ -34,4 +35,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div tab-content="files" ng-controller="PageAttachmentController" page-id="{{ $page->id or 0 }}">
|
||||
<h4>Attached Files</h4>
|
||||
<div class="padded files">
|
||||
<p class="muted small">Upload some files to display on your page. This are visible in the page sidebar.</p>
|
||||
<drop-zone upload-url="@{{getUploadUrl()}}" uploaded-to="@{{uploadedTo}}" event-success="uploadSuccess"></drop-zone>
|
||||
|
||||
<table class="no-style" tag-autosuggestions style="width: 100%;">
|
||||
<tbody ui-sortable="sortOptions" ng-model="files" >
|
||||
<tr ng-repeat="file in files track by $index">
|
||||
<td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
|
||||
<td ng-bind="file.name"></td>
|
||||
<td width="10" class="text-center text-neg" style="padding: 0;"><i class="zmdi zmdi-close"></i></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
@ -87,6 +87,11 @@ Route::group(['middleware' => 'auth'], function () {
|
||||
Route::delete('/{imageId}', 'ImageController@destroy');
|
||||
});
|
||||
|
||||
// File routes
|
||||
Route::post('/files/upload', 'FileController@upload');
|
||||
Route::get('/files/get/page/{pageId}', 'FileController@getFilesForPage');
|
||||
Route::put('/files/sort/page/{pageId}', 'FileController@sortFilesForPage');
|
||||
|
||||
// AJAX routes
|
||||
Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft');
|
||||
Route::get('/ajax/page/{id}', 'PageController@getPageAjax');
|
||||
|
2
storage/uploads/files/.gitignore
vendored
Executable file
2
storage/uploads/files/.gitignore
vendored
Executable file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
Loading…
Reference in New Issue
Block a user