Implement Entry Copy/Move
All checks were successful
Create Docker Image / Test Application (x86_64) (push) Successful in 28s
Create Docker Image / Build Docker Image (x86_64) (push) Successful in 1m39s
Create Docker Image / Build Docker Image (arm64) (push) Successful in 2m55s
Create Docker Image / Final Docker Image Manifest (push) Successful in 10s

This commit is contained in:
Deon George 2025-07-03 20:42:14 +08:00
parent 8f1c628324
commit 31524418b9
8 changed files with 303 additions and 8 deletions

View File

@ -509,4 +509,15 @@ final class Server
{ {
return Arr::get($this->rootDSE->subschemasubentry,0); return Arr::get($this->rootDSE->subschemasubentry,0);
} }
public function subordinates(string $dn,array $attrs=['dn']): ?LDAPCollection
{
return $this
->get(
dn: $dn,
attrs: array_merge($attrs,[]))
->rawFilter('(hassubordinates=TRUE)')
->search()
->get() ?: NULL;
}
} }

View File

@ -113,4 +113,27 @@ class AjaxController extends Controller
'may' => $oc->getMayAttrs()->pluck('name'), 'may' => $oc->getMayAttrs()->pluck('name'),
]; ];
} }
public function subordinates(?string $dn=NULL): array
{
$dn = $dn ? Crypt::decryptString($dn) : '';
// Sometimes our key has a command, so we'll ignore it
if (str_starts_with($dn,'*') && ($x=strpos($dn,'|')))
$dn = substr($dn,$x+1);
$result = collect();
// If no DN, we'll find all children
if (! $dn)
foreach (Server::baseDNs() as $base)
$result = $result->merge(config('server')
->subordinates($base->getDN()));
else
$result = config('server')
->subordinates(collect(explode(',',$dn))->last());
return
$result->map(fn($item)=>['id'=>$item->getDNSecure(),'value'=>$item->getDN()])
->toArray();
}
} }

View File

@ -112,6 +112,60 @@ class HomeController extends Controller
->with('updated',FALSE); ->with('updated',FALSE);
} }
public function entry_copy_move(Request $request): \Illuminate\Http\RedirectResponse|\Illuminate\View\View
{
$from_dn = Crypt::decryptString($request->post('dn'));
Log::info(sprintf('%s:Renaming [%s] to [%s]',self::LOGKEY,$from_dn,$request->post('to_dn')));
$o = clone config('server')->fetch($from_dn);
if (! $o)
return back()
->withInput()
->with('note',__('DN doesnt exist'));
$o->setDN($request->post('to_dn'));
$o->exists = FALSE;
// Add the RDN attribute to match the new RDN
$rdn = collect(explode(',',$request->post('to_dn')))->first();
list($attr,$value) = explode('=',$rdn);
$o->{$attr} = [Entry::TAG_NOTAG => $o->getObject($attr)->tagValuesOld(Entry::TAG_NOTAG)->push($value)->unique()];
Log::info(sprintf('%s:Copying [%s] to [%s]',self::LOGKEY,$from_dn,$o->getDN()));
try {
$o->save();
} catch (LdapRecordException $e) {
return Redirect::to('/')
->withInput(['_key'=>Crypt::encryptString('*dn|'.$from_dn)])
->with('failed',sprintf('%s: %s - %s: %s',
__('LDAP Server Error Code'),
$e->getDetailedError()?->getErrorCode() ?: $e->getMessage(),
$e->getDetailedError()?->getErrorMessage() ?: $e->getFile(),
$e->getDetailedError()?->getDiagnosticMessage() ?: $e->getLine(),
));
}
if ($request->post('delete') && $request->post('delete') === '1') {
Log::info(sprintf('%s:Deleting [%s] after copy',self::LOGKEY,$from_dn));
$x = $this->entry_delete($request);
return ($x->getSession()->has('success'))
? Redirect::to('/')
->withInput(['_key'=>Crypt::encryptString('*dn|'.$o->getDN())])
->with('success',__('Entry copied and deleted'))
: $x;
}
return Redirect::to('/')
->withInput(['_key'=>Crypt::encryptString('*dn|'.$o->getDN())])
->with('success',__('Entry copied'));
}
public function entry_create(EntryAddRequest $request): \Illuminate\Http\RedirectResponse public function entry_create(EntryAddRequest $request): \Illuminate\Http\RedirectResponse
{ {
$key = $this->request_key($request,collect(old())); $key = $this->request_key($request,collect(old()));
@ -154,7 +208,7 @@ class HomeController extends Controller
->with('failed',sprintf('%s: %s - %s: %s', ->with('failed',sprintf('%s: %s - %s: %s',
__('LDAP Server Error Code'), __('LDAP Server Error Code'),
$e->getDetailedError()->getErrorCode(), $e->getDetailedError()->getErrorCode(),
__($e->getDetailedError()->getErrorMessage()), $e->getDetailedError()->getErrorMessage(),
$e->getDetailedError()->getDiagnosticMessage(), $e->getDetailedError()->getDiagnosticMessage(),
)); ));
} }
@ -377,7 +431,7 @@ class HomeController extends Controller
->with('failed',sprintf('%s: %s - %s: %s', ->with('failed',sprintf('%s: %s - %s: %s',
__('LDAP Server Error Code'), __('LDAP Server Error Code'),
$e->getDetailedError()->getErrorCode(), $e->getDetailedError()->getErrorCode(),
__($e->getDetailedError()->getErrorMessage()), $e->getDetailedError()->getErrorMessage(),
$e->getDetailedError()->getDiagnosticMessage(), $e->getDetailedError()->getDiagnosticMessage(),
)); ));
} }
@ -435,8 +489,8 @@ class HomeController extends Controller
->with('dn',$key['dn']) ->with('dn',$key['dn'])
->with('o',$o) ->with('o',$o)
->with('page_actions',collect([ ->with('page_actions',collect([
'copy'=>FALSE,
'create'=>($x=($o->getObjects()->except('entryuuid')->count() > 0)), 'create'=>($x=($o->getObjects()->except('entryuuid')->count() > 0)),
'copy'=>$x,
'delete'=>(! is_null($xx=$o->getObject('hassubordinates')->value)) && ($xx === 'FALSE'), 'delete'=>(! is_null($xx=$o->getObject('hassubordinates')->value)) && ($xx === 'FALSE'),
'edit'=>$x, 'edit'=>$x,
'export'=>$x, 'export'=>$x,

View File

@ -92,11 +92,12 @@ class Entry extends Model
public function getAttributes(): array public function getAttributes(): array
{ {
return $this->objects return $this->objects
->filter(fn($item)=>(! $item->is_internal))
->flatMap(fn($item)=> ->flatMap(fn($item)=>
($item->no_attr_tags) $item->no_attr_tags
? [strtolower($item->name)=>$item->values] ? [strtolower($item->name)=>$item->values]
: $item->values : $item->values
->flatMap(fn($v,$k)=>[strtolower($item->name.($k !== self::TAG_NOTAG ? ';'.$k : ''))=>$v])) ->flatMap(fn($v,$k)=>[strtolower($item->name.(($k !== self::TAG_NOTAG) ? ';'.$k : ''))=>$v]))
->toArray(); ->toArray();
} }

View File

@ -19,7 +19,7 @@ attribute#objectclass .input-group-end:not(input.form-control) {
border-radius: 4px !important; border-radius: 4px !important;
} }
.input-group:first-child:not(.select-group) .select2-container--bootstrap-5 .select2-selection { .input-group:first-child:not(.select-group):not(:last-child) .select2-container--bootstrap-5 .select2-selection {
border-bottom-right-radius: unset; border-bottom-right-radius: unset;
border-top-right-radius: unset; border-top-right-radius: unset;
} }
@ -97,4 +97,9 @@ input.form-control.input-group-end {
/* Square UL items */ /* Square UL items */
ul.square { ul.square {
list-style-type: square; list-style-type: square;
}
/* Fix select2 selections when set up with d-none */
select[class*="d-none"] + span.select2 {
display: none;
} }

View File

@ -23,7 +23,9 @@
@endif @endif
@if($page_actions->get('copy')) @if($page_actions->get('copy'))
<li> <li>
<button class="btn btn-outline-dark p-1 m-1" id="entry-copy-move" data-bs-toggle="tooltip" data-bs-placement="bottom" title="@lang('Copy/Move')" disabled><i class="fas fa-fw fa-copy fs-5"></i></button> <span id="entry-copy-move" data-bs-toggle="modal" data-bs-target="#page-modal">
<button class="btn btn-outline-dark p-1 m-1" data-bs-toggle="tooltip" data-bs-placement="bottom" title="@lang('Copy/Move')"><i class="fas fa-fw fa-copy fs-5"></i></button>
</span>
</li> </li>
@endif @endif
@if($page_actions->get('edit')) @if($page_actions->get('edit'))
@ -226,6 +228,25 @@
var that = $(this).find('.modal-content'); var that = $(this).find('.modal-content');
switch ($(item.relatedTarget).attr('id')) { switch ($(item.relatedTarget).attr('id')) {
case 'entry-copy-move':
$.ajax({
method: 'GET',
url: '{{ url('modal/copy-move') }}/'+dn,
dataType: 'html',
cache: false,
beforeSend: function() {
that.empty().append('<span class="p-3"><i class="fas fa-3x fa-spinner fa-pulse"></i></span>');
},
success: function(data) {
that.empty().html(data);
},
error: function(e) {
if (e.status !== 412)
alert('That didnt work? Please try again....');
},
});
break;
case 'entry-delete': case 'entry-delete':
$.ajax({ $.ajax({
method: 'GET', method: 'GET',

View File

@ -0,0 +1,176 @@
<div class="modal-header bg-dark text-white">
<h1 class="modal-title fs-5">
<i class="fas fa-fw fa-exclamation-triangle"></i> @lang('Rename') <strong>{{ $x=Crypt::decryptString($dn) }}</strong>
</h1>
</div>
<div class="modal-body">
<span>
@lang('New') DN: <strong><span id="newdn" class="fs-4 opacity-50"><small class="fs-5">[@lang('Select Base')]</small></span></strong>
</span>
<br>
<br>
<form id="entry-rename-form" method="POST" action="{{ url('entry/copy-move') }}">
@csrf
<input type="hidden" name="dn" value="{{ $dn }}">
<input type="hidden" name="to_dn" value="">
<div class="row pb-3">
<div class="col-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="delete-checkbox" name="delete" value="1">
<label class="form-check-label" for="delete-checkbox">
<i class="fas fa-fw fa-trash"></i> @lang('Delete after Copy')
</label>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="input-group mb-3">
<span class="input-group-text" id="label">@lang('Select Base of Entry')</span>
<input type="text" id="rdn" class="form-control d-none" style="width:20%;" placeholder="{{ $rdn=collect(explode(',',$x))->first() }}" value="{{ $rdn }}">
<span class="input-group-text p-1 d-none">,</span>
<select class="form-select w-25 d-none" id="rename-subbase" disabled style="width:30%;"></select>
<span class="input-group-text p-1 d-none">,</span>
<select class="form-select w-25" id="rename-base" style="width:30%;" disabled></select>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<x-modal.close/>
<button id="entry-rename" type="button" class="btn btn-sm btn-primary">@lang('Copy')</button>
</div>
<script type="text/javascript">
function refreshdn(value) {
$('#newdn')
.removeClass('opacity-50')
.empty()
.append(value);
}
$(document).ready(function() {
var rdn = '{{ $rdn }}';
var base = '';
var that=$('#newdn');
// Get our bases
$.ajax({
method: 'POST',
url: '{{ url('ajax/subordinates') }}',
dataType: 'json',
cache: false,
beforeSend: function() {
that.empty().append('<span class="p-3"><i class="fas fa-xs fa-spinner fa-pulse"></i></span>');
},
success: function(data) {
that.empty().html('<small class="fs-5">[@lang('Select Base')]</small>');
$('#rename-base').children().remove();
$('#rename-base').append('<option value=""></option>');
data.forEach(function(item) {
$('#rename-base').append('<option value="'+item.id+'">'+item.value+'</option>');
});
$('#rename-base').prop('disabled',false);
},
error: function(e) {
if (e.status !== 412)
alert('That didnt work? Please try again....');
},
});
// The base DN container
$('#rename-base').select2({
theme: 'bootstrap-5',
dropdownAutoWidth: true,
width: 'style',
allowClear: false,
placeholder: 'Choose Base',
})
.on('change',function() {
$(this).prev().removeClass('d-none');
$('#rename-subbase').removeClass('d-none')
.prev().removeClass('d-none')
.prev().removeClass('d-none');
$('#label').empty().append("@lang('Complete Path')");
base = '';
if (x=$('#rename-subbase option:selected').text())
base += x+',';
base += $('#rename-base option:selected').text();
refreshdn(rdn+','+base);
var newdn = '';
$.ajax({
method: 'POST',
url:'{{ url('ajax/children') }}',
data: {_key: $(this).val() },
dataType: 'json',
cache: false,
beforeSend: function() {
newdn = that.text();
console.log(newdn);
that.empty().append('<span class="p-3"><i class="fas fa-xs fa-spinner fa-pulse"></i></span>');
},
success: function(data) {
that.empty().text(newdn);
$('#rename-subbase').children().remove();
$('#rename-subbase').append('<option value=""></option>');
data.forEach(function(item) {
$('#rename-subbase').append('<option value="'+item.item+'">'+item.title+'</option>');
});
$('#rename-subbase').prop('disabled',false);
},
error: function(e) {
if (e.status !== 412)
alert('That didnt work? Please try again....');
},
});
});
// Optional make a child a new branch
$('#rename-subbase').select2({
theme: 'bootstrap-5',
dropdownAutoWidth: true,
width: 'style',
allowClear: true,
placeholder: 'New Subordinate (optional)',
})
.on('change',function(item) {
base = '';
if (x=$('#rename-subbase option:selected').text())
base += x+',';
base += $('#rename-base option:selected').text();
refreshdn(rdn+','+base);
});
// Complete the RDN
$('#rdn').on('input',function(item) {
rdn = $(this).val();
refreshdn(rdn+','+base);
$('button[id=entry-rename]').attr('disabled',! rdn.includes('='));
})
// The submit button text
$('input#delete-checkbox').on('change',function() {
$('button#entry-rename').html($(this).prop('checked') ? '{{ __('Move') }}' : '{{ __('Copy') }}');
});
// Submit
$('button[id=entry-rename]').on('click',function() {
$('input[name=to_dn]').val(rdn+','+base);
$('form#entry-rename-form').submit();
});
});
</script>

View File

@ -43,18 +43,21 @@ Route::controller(HomeController::class)->group(function() {
}); });
Route::match(['get','post'],'entry/add','entry_add'); Route::match(['get','post'],'entry/add','entry_add');
Route::post('entry/attr/add/{id}','entry_attr_add');
Route::post('entry/create','entry_create'); Route::post('entry/create','entry_create');
Route::post('entry/copy-move','entry_copy_move');
Route::post('entry/delete','entry_delete'); Route::post('entry/delete','entry_delete');
Route::get('entry/export/{id}','entry_export'); Route::get('entry/export/{id}','entry_export');
Route::post('entry/password/check/','entry_password_check'); Route::post('entry/password/check/','entry_password_check');
Route::post('entry/attr/add/{id}','entry_attr_add');
Route::post('entry/objectclass/add','entry_objectclass_add'); Route::post('entry/objectclass/add','entry_objectclass_add');
Route::post('entry/rename','entry_rename'); Route::post('entry/rename','entry_rename');
Route::post('entry/update/commit','entry_update'); Route::post('entry/update/commit','entry_update');
Route::post('entry/update/pending','entry_pending_update'); Route::post('entry/update/pending','entry_pending_update');
Route::post('import/process/{type}','import'); Route::post('import/process/{type}','import');
Route::view('modal/copy-move/{dn}','modals.entry-copy-move');
Route::view('modal/delete/{dn}','modals.entry-delete'); Route::view('modal/delete/{dn}','modals.entry-delete');
Route::view('modal/export/{dn}','modals.entry-export'); Route::view('modal/export/{dn}','modals.entry-export');
Route::view('modal/rename/{dn}','modals.entry-rename'); Route::view('modal/rename/{dn}','modals.entry-rename');
@ -69,4 +72,5 @@ Route::controller(AjaxController::class)
Route::post('children','children'); Route::post('children','children');
Route::post('schema/view','schema_view'); Route::post('schema/view','schema_view');
Route::post('schema/objectclass/attrs/{id}','schema_objectclass_attrs'); Route::post('schema/objectclass/attrs/{id}','schema_objectclass_attrs');
Route::post('subordinates','subordinates');
}); });