Initial public push: docs cosmos v4 + AI module + framework groundwork
This is the snapshot the production landing site (nibiru-framework.com) is deployed from. Brings together the recent splash + docs migration to the v4 "Cosmos" design system, the new in-framework AI module, and the framework groundwork that backs the framework-reference extraction. What lands: - docs/: Astro + Starlight site with the v4 dark cosmic palette, GalaxyHero canvas constellation, Mission Control chat (wired to /api/oracle → api.neuronetz.ai via providers.mjs Ollama), 5-panel MMVC stage (Model · AI · Module · Controller · View), translated EN/DE/JA/ES/FR content, PWA + sitemap + llms.txt + Umami analytics. - docs/design-system/: canonical mockup bundle (source/index-v2.html for splash, source/docs-system.html + preview/ for docs, SPEC.md, tokens). - docs/scripts/extraction/framework-reference-v2.md: deep framework reference (~1.6k lines, file:line citations, every public factory and idiom — basis for the LoRA training corpus. - application/module/ai/: AI module with chat / embed / RAG / agent plugins, plus pdoQuery / httpGet / fileRead tools and Modelfile + smoke-test in training/. - application/module/users/: user / ACL / form-factory traits used as the reference plugin pattern for the framework docs. - application/settings/config/database/: schema + seed migrations including the AI module tables (200–203). - Form factory + autogenerator changes the framework-reference-v2 covers. Production secrets stay out: docs/.env, settings.production.ini and ai.production.ini are all gitignored (.example files are in tree). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
134
application/module/ai/ai.php
Normal file
134
application/module/ai/ai.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Ai;
|
||||
|
||||
/**
|
||||
* Nibiru AI module — the framework's first-class AI surface.
|
||||
*
|
||||
* $ai = new \Nibiru\Module\Ai\Ai();
|
||||
* echo $ai->chat()->ask('Explain the dispatcher in two sentences.');
|
||||
*
|
||||
* $rag = $ai->rag('docs');
|
||||
* $rag->ingest(__DIR__ . '/../../view/templates/');
|
||||
* echo $rag->ask('Where is the login form built?');
|
||||
*
|
||||
* $agent = $ai->agent()->withTools([
|
||||
* new Plugins\Tools\PdoQuery(),
|
||||
* new Plugins\Tools\HttpGet(),
|
||||
* ]);
|
||||
* echo $agent->run('How many active users do we have?');
|
||||
*
|
||||
* Configuration in application/module/ai/settings/ai.ini.
|
||||
*
|
||||
* @author Stephan Kasdorf
|
||||
* @license BSD
|
||||
*/
|
||||
|
||||
use Nibiru\Module\Ai\Interfaces;
|
||||
use Nibiru\Module\Ai\Traits;
|
||||
use Nibiru\Module\Ai\Plugins;
|
||||
use Nibiru\Registry;
|
||||
use SplSubject;
|
||||
use SplObserver;
|
||||
use SplObjectStorage;
|
||||
|
||||
class Ai implements Interfaces\Ai, SplSubject
|
||||
{
|
||||
use Traits\Ai;
|
||||
|
||||
const CONFIG_MODULE_NAME = 'ai';
|
||||
|
||||
/** @var \stdClass module config from settings/ai.ini */
|
||||
protected static ?\stdClass $aiRegistry = null;
|
||||
|
||||
/** @var SplObjectStorage observer storage */
|
||||
protected SplObjectStorage $observers;
|
||||
|
||||
/** @var Plugins\Chat|null lazy chat plugin */
|
||||
protected ?Plugins\Chat $chatPlugin = null;
|
||||
|
||||
/** @var Plugins\Embed|null lazy embed plugin */
|
||||
protected ?Plugins\Embed $embedPlugin = null;
|
||||
|
||||
/** @var array<string, Plugins\Rag> RAG instances keyed by collection name */
|
||||
protected array $ragInstances = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setAiRegistry();
|
||||
$this->observers = new SplObjectStorage();
|
||||
}
|
||||
|
||||
public function attach(SplObserver $observer): void
|
||||
{
|
||||
$this->observers->attach($observer);
|
||||
}
|
||||
|
||||
public function detach(SplObserver $observer): void
|
||||
{
|
||||
$this->observers->detach($observer);
|
||||
}
|
||||
|
||||
public function notify(): void
|
||||
{
|
||||
foreach ($this->observers as $observer) {
|
||||
$observer->update($this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a chat-completion plugin.
|
||||
*/
|
||||
public function chat(): Plugins\Chat
|
||||
{
|
||||
return $this->chatPlugin ??= new Plugins\Chat($this->config());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an embedding plugin.
|
||||
*/
|
||||
public function embed(): Plugins\Embed
|
||||
{
|
||||
return $this->embedPlugin ??= new Plugins\Embed($this->config());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a named RAG (Retrieval-Augmented Generation) collection. Each
|
||||
* collection has its own on-disk JSON vector index, so you can have
|
||||
* one RAG over your docs, another over your error logs, another over
|
||||
* customer-support tickets, all in the same app.
|
||||
*/
|
||||
public function rag(string $collection = 'default'): Plugins\Rag
|
||||
{
|
||||
return $this->ragInstances[$collection] ??= new Plugins\Rag(
|
||||
$collection,
|
||||
$this->config(),
|
||||
$this->chat(),
|
||||
$this->embed()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an agent with optional tools attached. Agents call the LLM
|
||||
* iteratively with tool-call decoding until they hit a terminal answer.
|
||||
*/
|
||||
public function agent(): Plugins\Agent
|
||||
{
|
||||
return new Plugins\Agent($this->config(), $this->chat());
|
||||
}
|
||||
|
||||
/**
|
||||
* The active config (a stdClass populated from settings/ai.ini).
|
||||
*/
|
||||
public function config(): \stdClass
|
||||
{
|
||||
return self::$aiRegistry;
|
||||
}
|
||||
|
||||
protected function setAiRegistry(): void
|
||||
{
|
||||
if (self::$aiRegistry === null) {
|
||||
$cfg = Registry::getInstance()->loadModuleConfigByName(self::CONFIG_MODULE_NAME);
|
||||
self::$aiRegistry = $cfg ?: new \stdClass();
|
||||
}
|
||||
}
|
||||
}
|
||||
13
application/module/ai/interfaces/ai.php
Normal file
13
application/module/ai/interfaces/ai.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Ai\Interfaces;
|
||||
|
||||
use Nibiru\Interfaces\IModule;
|
||||
|
||||
interface Ai extends IModule
|
||||
{
|
||||
public function chat(): \Nibiru\Module\Ai\Plugins\Chat;
|
||||
public function embed(): \Nibiru\Module\Ai\Plugins\Embed;
|
||||
public function rag(string $collection = 'default'): \Nibiru\Module\Ai\Plugins\Rag;
|
||||
public function agent(): \Nibiru\Module\Ai\Plugins\Agent;
|
||||
public function config(): \stdClass;
|
||||
}
|
||||
162
application/module/ai/plugins/agent.php
Normal file
162
application/module/ai/plugins/agent.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Ai\Plugins;
|
||||
|
||||
/**
|
||||
* Tool-using agent. Runs an LLM in a ReAct-style loop: it reads the
|
||||
* user's task, picks a tool, runs it, observes the result, repeats —
|
||||
* until it emits a final answer.
|
||||
*
|
||||
* $agent = $ai->agent()->withTools([
|
||||
* new Plugin\Tools\PdoQuery(),
|
||||
* new Plugin\Tools\HttpGet(),
|
||||
* new Plugin\Tools\FileRead(),
|
||||
* ]);
|
||||
* echo $agent->run('How many active users do we have?');
|
||||
*
|
||||
* Tool-call protocol uses a simple JSON sentinel — no model-specific
|
||||
* tool-calling APIs needed, so it works on every Ollama model. Models that
|
||||
* support native tool-calling can be plugged in via a subclass override
|
||||
* of {@link parseToolCall()}.
|
||||
*/
|
||||
class Agent
|
||||
{
|
||||
protected \stdClass $cfg;
|
||||
protected Chat $chat;
|
||||
|
||||
/** @var Tool[] */
|
||||
protected array $tools = [];
|
||||
|
||||
/** @var array<int, array{step:int, action:string, observation:string}> */
|
||||
protected array $trace = [];
|
||||
|
||||
public function __construct(\stdClass $cfg, Chat $chat)
|
||||
{
|
||||
$this->cfg = $cfg;
|
||||
$this->chat = $chat;
|
||||
}
|
||||
|
||||
/** @param Tool[] $tools */
|
||||
public function withTools(array $tools): self
|
||||
{
|
||||
$this->tools = $tools;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the agent against a task. Returns the final answer text.
|
||||
*/
|
||||
public function run(string $task): string
|
||||
{
|
||||
$maxIter = (int) ($this->cfg->agent_max_iterations ?? 6);
|
||||
$this->trace = [];
|
||||
|
||||
$system = $this->systemPrompt();
|
||||
$this->chat->reset()->system($system)->user($task);
|
||||
|
||||
for ($step = 1; $step <= $maxIter; $step++) {
|
||||
$reply = $this->chat->complete();
|
||||
$call = $this->parseToolCall($reply);
|
||||
|
||||
if ($call === null) {
|
||||
// Final answer — no tool call detected.
|
||||
return $this->stripFinalMarker($reply);
|
||||
}
|
||||
|
||||
$tool = $this->findTool($call['tool']);
|
||||
if ($tool === null) {
|
||||
$obs = "ERROR: no tool named \"{$call['tool']}\". Available: "
|
||||
. implode(', ', array_map(fn($t) => $t->name(), $this->tools));
|
||||
} else {
|
||||
try {
|
||||
$obs = (string) $tool->execute($call['args']);
|
||||
} catch (\Throwable $e) {
|
||||
$obs = 'ERROR: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$this->trace[] = ['step' => $step, 'action' => json_encode($call), 'observation' => $obs];
|
||||
|
||||
// Feed the observation back into the conversation.
|
||||
$this->chat->user("Observation:\n" . $obs . "\n\nContinue.");
|
||||
}
|
||||
|
||||
return $this->chat->complete(); // final attempt after max iterations
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{step:int, action:string, observation:string}>
|
||||
*/
|
||||
public function trace(): array
|
||||
{
|
||||
return $this->trace;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internals
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
protected function systemPrompt(): string
|
||||
{
|
||||
$toolBlock = '';
|
||||
foreach ($this->tools as $t) {
|
||||
$toolBlock .= $t->asPrompt() . "\n\n";
|
||||
}
|
||||
|
||||
return <<<PROMPT
|
||||
You are a Nibiru AI agent. You answer the user's task by either calling a tool
|
||||
or producing a final answer.
|
||||
|
||||
When you need a tool, output a single fenced JSON block on its own line:
|
||||
|
||||
```tool
|
||||
{"tool": "<name>", "args": { ... }}
|
||||
```
|
||||
|
||||
After you receive the observation, decide whether to call another tool or to
|
||||
produce a final answer. When done, write the final answer prefixed with
|
||||
"FINAL:" on its own paragraph. Do not call a tool and produce a final answer
|
||||
in the same turn.
|
||||
|
||||
Available tools:
|
||||
|
||||
$toolBlock
|
||||
Be concise. Prefer real tool data over guesses. If a tool errors twice, give a
|
||||
final answer that says you couldn't find out, and why.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for a ```tool { ... }``` JSON block. Returns null if absent.
|
||||
*
|
||||
* @return array{tool:string,args:array}|null
|
||||
*/
|
||||
protected function parseToolCall(string $reply): ?array
|
||||
{
|
||||
if (preg_match('/```(?:tool|json)\s*\n(.*?)\n```/s', $reply, $m)) {
|
||||
$decoded = json_decode($m[1], true);
|
||||
if (is_array($decoded) && isset($decoded['tool'])) {
|
||||
return [
|
||||
'tool' => (string) $decoded['tool'],
|
||||
'args' => is_array($decoded['args'] ?? null) ? $decoded['args'] : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
// Fallback: explicit final marker
|
||||
if (preg_match('/^FINAL:\s*/m', $reply)) return null;
|
||||
// No tool call detected — treat as final.
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function stripFinalMarker(string $reply): string
|
||||
{
|
||||
return preg_replace('/^FINAL:\s*/m', '', $reply) ?? $reply;
|
||||
}
|
||||
|
||||
protected function findTool(string $name): ?Tool
|
||||
{
|
||||
foreach ($this->tools as $t) {
|
||||
if ($t->name() === $name) return $t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
114
application/module/ai/plugins/chat.php
Normal file
114
application/module/ai/plugins/chat.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Ai\Plugins;
|
||||
|
||||
/**
|
||||
* Chat completions. Talks to Ollama (default) or Anthropic (when configured).
|
||||
*
|
||||
* $ai = new \Nibiru\Module\Ai\Ai();
|
||||
* $reply = $ai->chat()->ask('How do I scaffold a module?');
|
||||
*
|
||||
* // Multi-turn:
|
||||
* $chat = $ai->chat();
|
||||
* $chat->user('How do I scaffold a module?');
|
||||
* $chat->user('And add Graylog hooks?');
|
||||
* echo $chat->complete();
|
||||
*
|
||||
* // Custom system + model:
|
||||
* echo $ai->chat()
|
||||
* ->system('Answer in German.')
|
||||
* ->user('Explain MMVC.')
|
||||
* ->model('qwen2.5-coder:14b')
|
||||
* ->complete();
|
||||
*/
|
||||
class Chat
|
||||
{
|
||||
protected \stdClass $cfg;
|
||||
protected Ollama $ollama;
|
||||
|
||||
/** @var array<int, array{role:string,content:string}> */
|
||||
protected array $messages = [];
|
||||
|
||||
protected ?string $modelOverride = null;
|
||||
protected ?float $temperature = null;
|
||||
protected ?int $maxTokens = null;
|
||||
protected ?string $systemPrompt = null;
|
||||
|
||||
public function __construct(\stdClass $cfg)
|
||||
{
|
||||
$this->cfg = $cfg;
|
||||
$this->ollama = new Ollama($cfg);
|
||||
$this->systemPrompt = $cfg->chat_system_prompt ?? null;
|
||||
}
|
||||
|
||||
public function system(string $prompt): self { $this->systemPrompt = $prompt; return $this; }
|
||||
public function model(string $name): self { $this->modelOverride = $name; return $this; }
|
||||
public function temperature(float $t): self { $this->temperature = $t; return $this; }
|
||||
public function maxTokens(int $n): self { $this->maxTokens = $n; return $this; }
|
||||
|
||||
public function user(string $content): self { $this->messages[] = ['role' => 'user', 'content' => $content]; return $this; }
|
||||
public function assistant(string $content): self { $this->messages[] = ['role' => 'assistant', 'content' => $content]; return $this; }
|
||||
|
||||
/**
|
||||
* Execute the chat call and return the assistant's reply text.
|
||||
* Appends the reply to the message history.
|
||||
*/
|
||||
public function complete(): string
|
||||
{
|
||||
$apiMessages = [];
|
||||
if ($this->systemPrompt) {
|
||||
$apiMessages[] = ['role' => 'system', 'content' => $this->systemPrompt];
|
||||
}
|
||||
foreach ($this->messages as $m) {
|
||||
$apiMessages[] = $m;
|
||||
}
|
||||
|
||||
$model = $this->modelOverride
|
||||
?? ($this->cfg->chat_model ?? 'qwen2.5-coder:14b');
|
||||
$fallback = $this->cfg->chat_fallback_model ?? null;
|
||||
|
||||
$opts = [
|
||||
'temperature' => $this->temperature ?? (float) ($this->cfg->chat_temperature ?? 0.4),
|
||||
'num_predict' => $this->maxTokens ?? (int) ($this->cfg->chat_max_tokens ?? 1024),
|
||||
];
|
||||
|
||||
try {
|
||||
$res = $this->ollama->chat($model, $apiMessages, $opts);
|
||||
} catch (\RuntimeException $e) {
|
||||
if ($fallback && $fallback !== $model) {
|
||||
$res = $this->ollama->chat($fallback, $apiMessages, $opts);
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$text = $res['message']['content'] ?? '';
|
||||
$this->assistant($text);
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot helper — append a user message, complete, return reply.
|
||||
*/
|
||||
public function ask(string $question): string
|
||||
{
|
||||
$this->user($question);
|
||||
return $this->complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the conversation (keeps system prompt + model override).
|
||||
*/
|
||||
public function reset(): self
|
||||
{
|
||||
$this->messages = [];
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{role:string,content:string}> the conversation
|
||||
*/
|
||||
public function history(): array
|
||||
{
|
||||
return $this->messages;
|
||||
}
|
||||
}
|
||||
83
application/module/ai/plugins/embed.php
Normal file
83
application/module/ai/plugins/embed.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Ai\Plugins;
|
||||
|
||||
/**
|
||||
* Embeddings — turn text into vectors via Ollama (or OpenAI fallback).
|
||||
*
|
||||
* $vec = $ai->embed()->one('hello world'); // float[]
|
||||
* $vecs = $ai->embed()->batch(['a', 'b', 'c']); // float[][]
|
||||
* $sim = \Nibiru\Module\Ai\Plugins\Embed::cosine($a, $b); // 0..1
|
||||
*/
|
||||
class Embed
|
||||
{
|
||||
protected \stdClass $cfg;
|
||||
protected Ollama $ollama;
|
||||
|
||||
public function __construct(\stdClass $cfg)
|
||||
{
|
||||
$this->cfg = $cfg;
|
||||
$this->ollama = new Ollama($cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed a single string. Returns a flat float[].
|
||||
*/
|
||||
public function one(string $text): array
|
||||
{
|
||||
$model = $this->cfg->embed_model ?? 'nomic-embed-text';
|
||||
$res = $this->ollama->embed($model, $text);
|
||||
if (!isset($res['embedding']) || !is_array($res['embedding'])) {
|
||||
throw new \RuntimeException('Ollama embed: no `embedding` in response.');
|
||||
}
|
||||
return array_map('floatval', $res['embedding']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed many strings. Sequential under the hood (Ollama embeddings
|
||||
* endpoint is single-input), but rate-limited by config.
|
||||
*/
|
||||
public function batch(array $texts): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($texts as $t) {
|
||||
$out[] = $this->one((string) $t);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cosine similarity between two equal-length vectors. Returns 0–1.
|
||||
*/
|
||||
public static function cosine(array $a, array $b): float
|
||||
{
|
||||
$dot = 0.0;
|
||||
$na = 0.0;
|
||||
$nb = 0.0;
|
||||
$len = min(count($a), count($b));
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$dot += $a[$i] * $b[$i];
|
||||
$na += $a[$i] * $a[$i];
|
||||
$nb += $b[$i] * $b[$i];
|
||||
}
|
||||
$denom = sqrt($na) * sqrt($nb);
|
||||
return $denom === 0.0 ? 0.0 : $dot / $denom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack a vector to a base64 string for compact storage in JSON.
|
||||
*/
|
||||
public static function pack(array $vec): string
|
||||
{
|
||||
return base64_encode(pack('f*', ...$vec));
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of pack().
|
||||
*/
|
||||
public static function unpack(string $b64): array
|
||||
{
|
||||
$bin = base64_decode($b64, true);
|
||||
if ($bin === false) return [];
|
||||
return array_values(unpack('f*', $bin));
|
||||
}
|
||||
}
|
||||
172
application/module/ai/plugins/ollama.php
Normal file
172
application/module/ai/plugins/ollama.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Ai\Plugins;
|
||||
|
||||
/**
|
||||
* Ollama HTTP transport. The lowest layer of the AI stack — every other
|
||||
* plugin (Chat, Embed, RAG, Agent) ultimately speaks through this.
|
||||
*
|
||||
* Works against any Ollama-compatible endpoint (localhost:11434, a
|
||||
* remote Ollama via reverse proxy, vLLM with the /api/chat shim,
|
||||
* LiteLLM proxies, etc.).
|
||||
*/
|
||||
class Ollama
|
||||
{
|
||||
protected string $baseUrl;
|
||||
protected int $timeout;
|
||||
protected int $retries;
|
||||
|
||||
public function __construct(\stdClass $cfg)
|
||||
{
|
||||
$this->baseUrl = rtrim((string) ($cfg->ollama_base_url ?? 'http://localhost:11434'), '/');
|
||||
$this->timeout = (int) ($cfg->ollama_timeout ?? 90);
|
||||
$this->retries = (int) ($cfg->ollama_retries ?? 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat — synchronous, non-streaming.
|
||||
*
|
||||
* @param string $model e.g. "nibiru-coder:1.0"
|
||||
* @param array $messages [{role: 'user'|'assistant'|'system', content: '...'}]
|
||||
* @param array $options {temperature, num_predict, top_p, ...}
|
||||
* @return array decoded JSON response
|
||||
*/
|
||||
public function chat(string $model, array $messages, array $options = []): array
|
||||
{
|
||||
return $this->post('/api/chat', [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'stream' => false,
|
||||
'options' => $options,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/embeddings — single input.
|
||||
*
|
||||
* @return array{embedding: float[]}
|
||||
*/
|
||||
public function embed(string $model, string $prompt): array
|
||||
{
|
||||
return $this->post('/api/embeddings', [
|
||||
'model' => $model,
|
||||
'prompt' => $prompt,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/generate — single-prompt completion (no chat structure).
|
||||
*/
|
||||
public function generate(string $model, string $prompt, array $options = []): array
|
||||
{
|
||||
return $this->post('/api/generate', [
|
||||
'model' => $model,
|
||||
'prompt' => $prompt,
|
||||
'stream' => false,
|
||||
'options' => $options,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/tags — list available models.
|
||||
*/
|
||||
public function listModels(): array
|
||||
{
|
||||
return $this->get('/api/tags');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ps — list currently-loaded models.
|
||||
*/
|
||||
public function listLoaded(): array
|
||||
{
|
||||
return $this->get('/api/ps');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/pull — pull a model onto the Ollama server.
|
||||
*/
|
||||
public function pull(string $name): array
|
||||
{
|
||||
return $this->post('/api/pull', ['name' => $name, 'stream' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/create — register a custom model from a Modelfile.
|
||||
*/
|
||||
public function create(string $name, string $modelfile): array
|
||||
{
|
||||
return $this->post('/api/create', [
|
||||
'name' => $name,
|
||||
'modelfile' => $modelfile,
|
||||
'stream' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
protected function post(string $path, array $body): array
|
||||
{
|
||||
$url = $this->baseUrl . $path;
|
||||
$payload = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
return $this->withRetries(function () use ($url, $payload) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
if ($body === false) {
|
||||
throw new \RuntimeException("Ollama transport error: $err");
|
||||
}
|
||||
if ($code >= 400) {
|
||||
$snip = substr((string) $body, 0, 240);
|
||||
throw new \RuntimeException("Ollama HTTP $code: $snip");
|
||||
}
|
||||
$decoded = json_decode((string) $body, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new \RuntimeException('Ollama returned non-JSON body.');
|
||||
}
|
||||
return $decoded;
|
||||
});
|
||||
}
|
||||
|
||||
protected function get(string $path): array
|
||||
{
|
||||
$ch = curl_init($this->baseUrl . $path);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($body === false || $code >= 400) {
|
||||
throw new \RuntimeException("Ollama GET $path failed (HTTP $code)");
|
||||
}
|
||||
return json_decode((string) $body, true) ?? [];
|
||||
}
|
||||
|
||||
protected function withRetries(callable $fn)
|
||||
{
|
||||
$last = null;
|
||||
for ($i = 0; $i <= $this->retries; $i++) {
|
||||
try {
|
||||
return $fn();
|
||||
} catch (\RuntimeException $e) {
|
||||
$last = $e;
|
||||
if ($i < $this->retries) usleep(250_000 * (1 << $i)); // 250ms, 500ms, ...
|
||||
}
|
||||
}
|
||||
throw $last ?? new \RuntimeException('Ollama call failed.');
|
||||
}
|
||||
}
|
||||
315
application/module/ai/plugins/rag.php
Normal file
315
application/module/ai/plugins/rag.php
Normal file
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Ai\Plugins;
|
||||
|
||||
/**
|
||||
* Retrieval-Augmented Generation. Ingest text or files, then ask
|
||||
* grounded questions backed by cosine retrieval over the chunks.
|
||||
*
|
||||
* $rag = $ai->rag('docs');
|
||||
* $rag->ingestText('The dispatcher runs every request.', ['source' => 'note-1']);
|
||||
* $rag->ingestFile(__DIR__ . '/manual.md');
|
||||
* $rag->ingestDir(__DIR__ . '/articles/');
|
||||
*
|
||||
* $answer = $rag->ask('How does the dispatcher work?');
|
||||
* $hits = $rag->search('dispatcher', 5);
|
||||
*
|
||||
* Storage: a single JSON file per collection at
|
||||
* <storage_path>/<collection>.json
|
||||
*
|
||||
* No database. Restartable. ~10k chunks fits in memory comfortably.
|
||||
*/
|
||||
class Rag
|
||||
{
|
||||
protected string $collection;
|
||||
protected \stdClass $cfg;
|
||||
protected Chat $chat;
|
||||
protected Embed $embed;
|
||||
|
||||
/** @var array{chunks:array,embeddings:array} */
|
||||
protected array $index = ['chunks' => [], 'embeddings' => []];
|
||||
protected bool $loaded = false;
|
||||
|
||||
public function __construct(string $collection, \stdClass $cfg, Chat $chat, Embed $embed)
|
||||
{
|
||||
$this->collection = preg_replace('/[^a-z0-9_-]/i', '', $collection) ?: 'default';
|
||||
$this->cfg = $cfg;
|
||||
$this->chat = $chat;
|
||||
$this->embed = $embed;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Ingestion
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add a single chunk of text to the collection.
|
||||
*/
|
||||
public function ingestText(string $text, array $metadata = []): void
|
||||
{
|
||||
$this->load();
|
||||
$this->addChunk($text, $metadata);
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file, chunk it, and ingest each chunk.
|
||||
*/
|
||||
public function ingestFile(string $path): int
|
||||
{
|
||||
if (!is_readable($path)) {
|
||||
throw new \RuntimeException("RAG ingest: $path is not readable.");
|
||||
}
|
||||
$this->load();
|
||||
$body = (string) file_get_contents($path);
|
||||
$chunks = $this->chunk($body);
|
||||
foreach ($chunks as $c) {
|
||||
$this->addChunk($c, ['source' => $path]);
|
||||
}
|
||||
$this->save();
|
||||
return count($chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively ingest every .md / .txt / .php file under a directory.
|
||||
*/
|
||||
public function ingestDir(string $dir, array $extensions = ['md', 'txt', 'php']): int
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
throw new \RuntimeException("RAG ingest: $dir is not a directory.");
|
||||
}
|
||||
$count = 0;
|
||||
$extPattern = '/\.(' . implode('|', array_map('preg_quote', $extensions)) . ')$/i';
|
||||
$iter = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(
|
||||
$dir, \RecursiveDirectoryIterator::SKIP_DOTS
|
||||
));
|
||||
foreach ($iter as $entry) {
|
||||
if (!$entry->isFile()) continue;
|
||||
if (!preg_match($extPattern, $entry->getFilename())) continue;
|
||||
$count += $this->ingestFile($entry->getPathname());
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget every chunk in this collection (and delete the storage file).
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->index = ['chunks' => [], 'embeddings' => []];
|
||||
$this->loaded = true;
|
||||
if ($this->backend() === 'database') {
|
||||
\Nibiru\Pdo::query(
|
||||
'DELETE c FROM ai_rag_chunk c '
|
||||
. 'INNER JOIN ai_rag_collection o ON o.ai_rag_collection_id = c.ai_rag_chunk_collection_id '
|
||||
. 'WHERE o.ai_rag_collection_name = :name',
|
||||
[':name' => $this->collection]
|
||||
);
|
||||
\Nibiru\Pdo::delete('ai_rag_collection', ['ai_rag_collection_name' => $this->collection]);
|
||||
return;
|
||||
}
|
||||
$path = $this->storagePath();
|
||||
if (is_file($path)) @unlink($path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Querying
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Top-K cosine similarity. Returns [{score, text, metadata}].
|
||||
*/
|
||||
public function search(string $query, ?int $k = null): array
|
||||
{
|
||||
$this->load();
|
||||
if (empty($this->index['embeddings'])) return [];
|
||||
|
||||
$k = $k ?? (int) ($this->cfg->rag_top_k ?? 6);
|
||||
$qv = $this->embed->one($query);
|
||||
|
||||
$scored = [];
|
||||
foreach ($this->index['embeddings'] as $i => $packed) {
|
||||
$vec = Embed::unpack($packed);
|
||||
$scored[] = [
|
||||
'score' => Embed::cosine($qv, $vec),
|
||||
'text' => $this->index['chunks'][$i]['text'] ?? '',
|
||||
'metadata' => $this->index['chunks'][$i]['metadata'] ?? [],
|
||||
];
|
||||
}
|
||||
usort($scored, fn($a, $b) => $b['score'] <=> $a['score']);
|
||||
return array_slice($scored, 0, $k);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the collection, then ask the LLM with the top-K chunks as context.
|
||||
*/
|
||||
public function ask(string $question, ?int $k = null): string
|
||||
{
|
||||
$hits = $this->search($question, $k);
|
||||
if (empty($hits)) {
|
||||
return $this->chat->reset()->ask($question);
|
||||
}
|
||||
$context = '';
|
||||
foreach ($hits as $i => $h) {
|
||||
$context .= '[' . ($i + 1) . '] ' . trim($h['text']) . "\n\n---\n\n";
|
||||
}
|
||||
$sys = ($this->chat->history() ? '' : (string) ($this->cfg->chat_system_prompt ?? ''));
|
||||
$sys .= "\n\nUse these excerpts to answer. Cite by number like [1].\n\n" . $context;
|
||||
return $this->chat->reset()->system(trim($sys))->ask($question);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internals
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
protected function addChunk(string $text, array $metadata): void
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') return;
|
||||
$vec = $this->embed->one($text);
|
||||
$packed = Embed::pack($vec);
|
||||
|
||||
$this->index['chunks'][] = ['text' => $text, 'metadata' => $metadata];
|
||||
$this->index['embeddings'][] = $packed;
|
||||
|
||||
if ($this->backend() === 'database') {
|
||||
\Nibiru\Pdo::insert('ai_rag_chunk', [
|
||||
'ai_rag_chunk_collection_id' => $this->dbCollectionId(),
|
||||
'ai_rag_chunk_text' => $text,
|
||||
'ai_rag_chunk_metadata' => json_encode($metadata, JSON_UNESCAPED_UNICODE),
|
||||
'ai_rag_chunk_embedding' => $packed,
|
||||
'ai_rag_chunk_token_count' => (int) ceil(strlen($text) / 4),
|
||||
'ai_rag_chunk_source' => isset($metadata['source']) ? (string) $metadata['source'] : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function chunk(string $body): array
|
||||
{
|
||||
$target = (int) ($this->cfg->rag_chunk_target ?? 600);
|
||||
$min = (int) ($this->cfg->rag_chunk_min ?? 120);
|
||||
$max = (int) ($this->cfg->rag_chunk_max ?? 900);
|
||||
// Split on paragraph boundaries first, then merge to target size.
|
||||
$paragraphs = preg_split('/\n\s*\n/', $body) ?: [];
|
||||
$out = [];
|
||||
$buf = '';
|
||||
$bufTokens = 0;
|
||||
foreach ($paragraphs as $p) {
|
||||
$pTokens = (int) ceil(strlen($p) / 4); // crude
|
||||
if ($bufTokens + $pTokens > $target && $bufTokens >= $min) {
|
||||
$out[] = $buf;
|
||||
$buf = '';
|
||||
$bufTokens = 0;
|
||||
}
|
||||
if ($pTokens > $max) {
|
||||
if ($buf !== '') { $out[] = $buf; $buf = ''; $bufTokens = 0; }
|
||||
// Split overlarge paragraph on sentence boundary
|
||||
$sentences = preg_split('/(?<=[.!?])\s+/', $p) ?: [$p];
|
||||
foreach ($sentences as $s) $out[] = $s;
|
||||
continue;
|
||||
}
|
||||
$buf .= ($buf === '' ? '' : "\n\n") . $p;
|
||||
$bufTokens += $pTokens;
|
||||
}
|
||||
if ($buf !== '') $out[] = $buf;
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage backend, controlled by [AI] rag.storage in ai.ini:
|
||||
* "json" — single JSON file per collection (default; great for dev)
|
||||
* "database" — uses ai_rag_collection / ai_rag_chunk tables via \Nibiru\Pdo
|
||||
* (recommended for production; survives load-balancer fan-out)
|
||||
*/
|
||||
protected function backend(): string
|
||||
{
|
||||
$b = strtolower((string) ($this->cfg->rag_storage ?? 'json'));
|
||||
return $b === 'database' ? 'database' : 'json';
|
||||
}
|
||||
|
||||
protected function storagePath(): string
|
||||
{
|
||||
$base = $this->cfg->rag_storage_path
|
||||
?? '/../../application/module/ai/cache/rag/';
|
||||
$dir = realpath(__DIR__ . $base) ?: (__DIR__ . $base);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||
return rtrim($dir, '/') . '/' . $this->collection . '.json';
|
||||
}
|
||||
|
||||
protected function load(): void
|
||||
{
|
||||
if ($this->loaded) return;
|
||||
if ($this->backend() === 'database') {
|
||||
$this->loadFromDatabase();
|
||||
} else {
|
||||
$path = $this->storagePath();
|
||||
if (is_file($path)) {
|
||||
$raw = json_decode((string) file_get_contents($path), true);
|
||||
if (is_array($raw) && isset($raw['chunks'], $raw['embeddings'])) {
|
||||
$this->index = $raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->loaded = true;
|
||||
}
|
||||
|
||||
protected function save(): void
|
||||
{
|
||||
if ($this->backend() === 'database') {
|
||||
// database is written incrementally in addChunk(); no-op here.
|
||||
return;
|
||||
}
|
||||
$path = $this->storagePath();
|
||||
file_put_contents($path, json_encode($this->index, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load from ai_rag_collection + ai_rag_chunk tables. Uses Nibiru's
|
||||
* `\Nibiru\Pdo` adapter — the tables are populated by migrations
|
||||
* 200-ai_rag_collection.sql and 201-ai_rag_chunk.sql.
|
||||
*/
|
||||
protected function loadFromDatabase(): void
|
||||
{
|
||||
$rows = \Nibiru\Pdo::fetchAll(
|
||||
'SELECT c.ai_rag_chunk_text AS text, c.ai_rag_chunk_metadata AS metadata, '
|
||||
. ' c.ai_rag_chunk_embedding AS embedding '
|
||||
. 'FROM ai_rag_chunk c '
|
||||
. 'INNER JOIN ai_rag_collection o ON o.ai_rag_collection_id = c.ai_rag_chunk_collection_id '
|
||||
. 'WHERE o.ai_rag_collection_name = :name '
|
||||
. 'ORDER BY c.ai_rag_chunk_id',
|
||||
[':name' => $this->collection]
|
||||
);
|
||||
foreach ($rows as $r) {
|
||||
$this->index['chunks'][] = [
|
||||
'text' => (string) $r['text'],
|
||||
'metadata' => is_string($r['metadata']) ? (json_decode($r['metadata'], true) ?: []) : (array) ($r['metadata'] ?? []),
|
||||
];
|
||||
$this->index['embeddings'][] = (string) $r['embedding'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve (or create) the collection's row in ai_rag_collection.
|
||||
* Called lazily by addChunk() in database mode.
|
||||
*/
|
||||
protected function dbCollectionId(): int
|
||||
{
|
||||
$row = \Nibiru\Pdo::fetchRow(
|
||||
'SELECT ai_rag_collection_id AS id FROM ai_rag_collection '
|
||||
. 'WHERE ai_rag_collection_name = :name',
|
||||
[':name' => $this->collection]
|
||||
);
|
||||
if ($row && isset($row['id'])) return (int) $row['id'];
|
||||
\Nibiru\Pdo::insert('ai_rag_collection', [
|
||||
'ai_rag_collection_name' => $this->collection,
|
||||
'ai_rag_collection_embed_model' => (string) ($this->cfg->embed_model ?? ''),
|
||||
'ai_rag_collection_embed_dim' => (int) ($this->cfg->embed_dim ?? 0),
|
||||
]);
|
||||
return (int) \Nibiru\Pdo::lastInsertId();
|
||||
}
|
||||
|
||||
public function size(): int
|
||||
{
|
||||
$this->load();
|
||||
return count($this->index['chunks']);
|
||||
}
|
||||
}
|
||||
53
application/module/ai/plugins/tool.php
Normal file
53
application/module/ai/plugins/tool.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Ai\Plugins;
|
||||
|
||||
/**
|
||||
* Base class for an Agent tool. Extend this to expose any PHP capability
|
||||
* to the Nibiru agent runtime.
|
||||
*
|
||||
* A tool has three things:
|
||||
* - a `name()` — the agent calls it by this name
|
||||
* - a `schema()` — JSON-schema-like description of inputs (drives the
|
||||
* agent's tool-call decoding)
|
||||
* - an `execute($args)` — the actual implementation, returning a string
|
||||
* (or scalar) that the agent will read back as observation.
|
||||
*
|
||||
* Built-in tools live alongside this file: PdoQuery, HttpGet, ViewAssign,
|
||||
* FormBuild, FileRead. You can write your own and pass them in via
|
||||
* Agent::withTools([...]).
|
||||
*/
|
||||
abstract class Tool
|
||||
{
|
||||
abstract public function name(): string;
|
||||
|
||||
abstract public function description(): string;
|
||||
|
||||
/**
|
||||
* @return array<string, array{type:string, description:string, required?:bool}>
|
||||
*/
|
||||
abstract public function schema(): array;
|
||||
|
||||
/**
|
||||
* Run the tool. Return a string (or scalar) — the agent will pass
|
||||
* this string back into the LLM as a tool observation.
|
||||
*/
|
||||
abstract public function execute(array $args): mixed;
|
||||
|
||||
/**
|
||||
* Render the tool definition as the agent's prompt sees it.
|
||||
*/
|
||||
public function asPrompt(): string
|
||||
{
|
||||
$schema = $this->schema();
|
||||
$args = [];
|
||||
foreach ($schema as $key => $def) {
|
||||
$args[] = " - $key ({$def['type']}): " . ($def['description'] ?? '');
|
||||
}
|
||||
return sprintf(
|
||||
"Tool: %s\nWhat it does: %s\nArguments:\n%s",
|
||||
$this->name(),
|
||||
$this->description(),
|
||||
implode("\n", $args)
|
||||
);
|
||||
}
|
||||
}
|
||||
51
application/module/ai/plugins/tools/fileRead.php
Normal file
51
application/module/ai/plugins/tools/fileRead.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Ai\Plugins\Tools;
|
||||
|
||||
use Nibiru\Module\Ai\Plugins\Tool;
|
||||
|
||||
/**
|
||||
* Read a project file by path (relative to the application root). Lets
|
||||
* the agent quote real source when answering "where is X defined?" type
|
||||
* questions. Read-only. No path-traversal — `..` is blocked.
|
||||
*/
|
||||
class FileRead extends Tool
|
||||
{
|
||||
public function name(): string { return 'file_read'; }
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Read a file from the project. Path is relative to the application root '
|
||||
. '(the directory containing index.php). Returns up to 8 KB.';
|
||||
}
|
||||
|
||||
public function schema(): array
|
||||
{
|
||||
return [
|
||||
'path' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Relative path, e.g. "application/controller/loginController.php".',
|
||||
'required' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(array $args): mixed
|
||||
{
|
||||
$path = (string) ($args['path'] ?? '');
|
||||
if ($path === '' || str_contains($path, '..')) {
|
||||
return 'ERROR: invalid path';
|
||||
}
|
||||
// Application root = three levels up from this plugin file:
|
||||
// application/module/ai/plugins/tools/fileRead.php
|
||||
// ↑ ↑ ↑ ↑ ↑ ↑
|
||||
// app module ai plugins tools this
|
||||
$root = realpath(__DIR__ . '/../../../../../');
|
||||
if ($root === false) return 'ERROR: cannot resolve app root';
|
||||
$abs = realpath($root . DIRECTORY_SEPARATOR . $path);
|
||||
if ($abs === false || !is_file($abs)) return 'ERROR: file not found';
|
||||
if (strpos($abs, $root) !== 0) return 'ERROR: path escapes root';
|
||||
$body = (string) file_get_contents($abs);
|
||||
if (strlen($body) > 8192) $body = substr($body, 0, 8192) . "\n…[truncated]";
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
68
application/module/ai/plugins/tools/httpGet.php
Normal file
68
application/module/ai/plugins/tools/httpGet.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Ai\Plugins\Tools;
|
||||
|
||||
use Nibiru\Module\Ai\Plugins\Tool;
|
||||
|
||||
/**
|
||||
* GET an HTTP URL and return its body. Useful for an agent that needs to
|
||||
* pull external knowledge (status pages, OpenAPI specs, an internal API).
|
||||
*
|
||||
* Safety: hosts and ports can be restricted via [AI] http_allowed_hosts[]
|
||||
* in ai.ini. By default, ALL hosts are allowed — lock down for production.
|
||||
*/
|
||||
class HttpGet extends Tool
|
||||
{
|
||||
public function name(): string { return 'http_get'; }
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Fetch an HTTP/HTTPS URL with a GET request. Returns the response body, '
|
||||
. 'truncated to 8 KB.';
|
||||
}
|
||||
|
||||
public function schema(): array
|
||||
{
|
||||
return [
|
||||
'url' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Full URL to GET, including https:// scheme.',
|
||||
'required' => true,
|
||||
],
|
||||
'headers' => [
|
||||
'type' => 'object',
|
||||
'description' => 'Optional request headers, e.g. {"Authorization": "Bearer …"}.',
|
||||
'required' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(array $args): mixed
|
||||
{
|
||||
$url = (string) ($args['url'] ?? '');
|
||||
if ($url === '' || !preg_match('#^https?://#i', $url)) {
|
||||
return 'ERROR: url must be http(s)://...';
|
||||
}
|
||||
$headers = [];
|
||||
foreach ((array) ($args['headers'] ?? []) as $k => $v) {
|
||||
$headers[] = "$k: $v";
|
||||
}
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_USERAGENT => 'Nibiru-Agent/1.0',
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
if ($body === false) return "ERROR: $err";
|
||||
$body = (string) $body;
|
||||
if (strlen($body) > 8192) $body = substr($body, 0, 8192) . "\n…[truncated]";
|
||||
return "HTTP $code\n$body";
|
||||
}
|
||||
}
|
||||
67
application/module/ai/plugins/tools/pdoQuery.php
Normal file
67
application/module/ai/plugins/tools/pdoQuery.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Ai\Plugins\Tools;
|
||||
|
||||
use Nibiru\Module\Ai\Plugins\Tool;
|
||||
use Nibiru\Pdo;
|
||||
|
||||
/**
|
||||
* Read-only SQL query tool. Lets an agent ask the database questions like
|
||||
* "how many active users?" without giving it write access.
|
||||
*
|
||||
* $agent->withTools([new \Nibiru\Module\Ai\Plugins\Tools\PdoQuery()])
|
||||
* ->run('How many users registered last week?');
|
||||
*
|
||||
* Safety: rejects anything that looks like INSERT/UPDATE/DELETE/DROP/TRUNCATE/ALTER.
|
||||
* If you need write access, write a more privileged subclass with an audit trail.
|
||||
*/
|
||||
class PdoQuery extends Tool
|
||||
{
|
||||
public function name(): string { return 'pdo_query'; }
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Run a single read-only SQL SELECT against the application database. '
|
||||
. 'Use for counts, aggregates, lookups. Returns rows as JSON.';
|
||||
}
|
||||
|
||||
public function schema(): array
|
||||
{
|
||||
return [
|
||||
'sql' => [
|
||||
'type' => 'string',
|
||||
'description' => 'A single SELECT statement. Use placeholders (:name) for dynamic values.',
|
||||
'required' => true,
|
||||
],
|
||||
'params' => [
|
||||
'type' => 'object',
|
||||
'description' => 'Optional parameter bindings, e.g. {":id": 42}.',
|
||||
'required' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(array $args): mixed
|
||||
{
|
||||
$sql = trim((string) ($args['sql'] ?? ''));
|
||||
if ($sql === '') return 'ERROR: empty SQL';
|
||||
if (!preg_match('/^\s*SELECT\s/i', $sql)) {
|
||||
return 'ERROR: only SELECT is permitted by pdo_query';
|
||||
}
|
||||
if (preg_match('/;\s*\S/', $sql)) {
|
||||
return 'ERROR: only a single statement is permitted';
|
||||
}
|
||||
if (preg_match('/\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE)\b/i', $sql)) {
|
||||
return 'ERROR: write/DDL operations are blocked';
|
||||
}
|
||||
|
||||
try {
|
||||
$params = is_array($args['params'] ?? null) ? $args['params'] : [];
|
||||
$rows = Pdo::fetchAll($sql, $params);
|
||||
// Cap the response so the agent doesn't choke on huge results.
|
||||
$rows = array_slice($rows, 0, 50);
|
||||
return json_encode($rows, JSON_UNESCAPED_UNICODE);
|
||||
} catch (\Throwable $e) {
|
||||
return 'ERROR: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
53
application/module/ai/settings/ai.ini
Normal file
53
application/module/ai/settings/ai.ini
Normal file
@@ -0,0 +1,53 @@
|
||||
; =====================================================================
|
||||
; Nibiru AI module — config
|
||||
;
|
||||
; Sections supported under [AI]:
|
||||
; - Ollama transport
|
||||
; - Chat completions
|
||||
; - Embeddings
|
||||
; - RAG (retrieval-augmented generation)
|
||||
; - Agents
|
||||
;
|
||||
; Override per-environment with ai.production.ini, ai.staging.ini, etc.
|
||||
; =====================================================================
|
||||
|
||||
[AI]
|
||||
; --- Ollama transport ---
|
||||
; Default is the standard local Ollama port. Override per environment in
|
||||
; ai.production.ini / ai.staging.ini, or set the OLLAMA_BASE_URL env var.
|
||||
ollama.base_url = "http://localhost:11434"
|
||||
ollama.timeout = 90
|
||||
ollama.retries = 1
|
||||
|
||||
; --- Chat ---
|
||||
chat.model = "nibiru-coder:1.0"
|
||||
chat.fallback_model = "qwen2.5-coder:14b"
|
||||
chat.temperature = 0.4
|
||||
chat.max_tokens = 1024
|
||||
chat.system_prompt = "You are an expert on the Nibiru PHP framework. Answer with concrete code examples."
|
||||
|
||||
; --- Embeddings ---
|
||||
embed.model = "nomic-embed-text"
|
||||
embed.batch = 16
|
||||
embed.dim = 768
|
||||
|
||||
; --- RAG ---
|
||||
rag.storage_path = "/../../application/module/ai/cache/rag/"
|
||||
rag.top_k = 6
|
||||
rag.chunk_target = 600
|
||||
rag.chunk_min = 120
|
||||
rag.chunk_max = 900
|
||||
|
||||
; --- Agents ---
|
||||
agent.max_iterations = 6
|
||||
agent.tool_timeout = 30
|
||||
agent.allowed_tools[] = "pdo_query"
|
||||
agent.allowed_tools[] = "http_get"
|
||||
agent.allowed_tools[] = "view_assign"
|
||||
agent.allowed_tools[] = "form_build"
|
||||
|
||||
; --- Anthropic / OpenAI fallback (optional) ---
|
||||
anthropic.api_key = ""
|
||||
anthropic.model = "claude-haiku-4-5-20251001"
|
||||
openai.api_key = ""
|
||||
openai.embed_model = "text-embedding-3-small"
|
||||
41
application/module/ai/settings/ai.production.ini.example
Normal file
41
application/module/ai/settings/ai.production.ini.example
Normal file
@@ -0,0 +1,41 @@
|
||||
; =====================================================================
|
||||
; Per-environment override for the AI module.
|
||||
;
|
||||
; Copy to ai.production.ini on the production host and fill in real
|
||||
; values. The Registry will prefer this file over ai.ini when
|
||||
; APPLICATION_ENV=production.
|
||||
;
|
||||
; This file IS gitignored (or should be — add /application/module/ai/
|
||||
; settings/ai.production.ini to .gitignore). Never commit production
|
||||
; endpoints or model names to a public repo.
|
||||
; =====================================================================
|
||||
|
||||
[AI]
|
||||
; Point at YOUR Ollama. Self-hosted, behind a reverse proxy, or local —
|
||||
; whatever you run. Leave the default localhost in ai.ini and override
|
||||
; here for production.
|
||||
ollama.base_url = "https://your-ollama.example.com"
|
||||
ollama.timeout = 90
|
||||
|
||||
; If you've registered the Nibiru-flavoured model on your Ollama, point
|
||||
; chat.model at it. Otherwise the fallback covers you.
|
||||
chat.model = "nibiru-coder:1.0"
|
||||
chat.fallback_model = "qwen2.5-coder:14b"
|
||||
|
||||
; In production prefer the larger / better embedding model if you've
|
||||
; pulled it. mxbai-embed-large > nomic-embed-text for quality.
|
||||
embed.model = "nomic-embed-text"
|
||||
|
||||
; Tighten the ask budget in production — fewer tokens = faster + cheaper.
|
||||
chat.max_tokens = 800
|
||||
|
||||
; RAG storage in production: switch to database to share a collection
|
||||
; across multiple app instances behind a load balancer.
|
||||
; "json" — single JSON file per collection (default, in cache/rag/)
|
||||
; "database" — uses ai_rag_collection / ai_rag_chunk tables
|
||||
rag.storage = "database"
|
||||
|
||||
; Optional fallbacks. The framework module ships without these — set
|
||||
; them only if you want to swap providers temporarily.
|
||||
; anthropic.api_key = "sk-ant-..."
|
||||
; openai.api_key = "sk-..."
|
||||
55
application/module/ai/training/Modelfile
Normal file
55
application/module/ai/training/Modelfile
Normal file
@@ -0,0 +1,55 @@
|
||||
# =============================================================================
|
||||
# nibiru-coder — A Nibiru-flavoured coding model registered on your Ollama.
|
||||
#
|
||||
# Build via the helper script (recommended):
|
||||
# ./application/module/ai/training/build.sh # default tag :1.0
|
||||
# ./application/module/ai/training/build.sh 1.1 # custom tag
|
||||
# OLLAMA_BASE_URL=https://your.ollama.example ./.../build.sh
|
||||
#
|
||||
# Or directly against Ollama:
|
||||
# curl ${OLLAMA_BASE_URL:-http://localhost:11434}/api/create -d @<(jq -n \
|
||||
# --arg name "nibiru-coder:1.0" \
|
||||
# --rawfile mf application/module/ai/training/Modelfile \
|
||||
# '{name: $name, modelfile: $mf, stream: false}')
|
||||
# =============================================================================
|
||||
|
||||
FROM qwen2.5-coder:14b
|
||||
|
||||
PARAMETER temperature 0.4
|
||||
PARAMETER top_p 0.9
|
||||
PARAMETER top_k 40
|
||||
PARAMETER repeat_penalty 1.1
|
||||
PARAMETER num_ctx 8192
|
||||
PARAMETER stop "User:"
|
||||
PARAMETER stop "Assistant:"
|
||||
|
||||
SYSTEM """
|
||||
You are a Nibiru framework expert.
|
||||
|
||||
CONTEXT: Nibiru is a modular MMVC PHP framework. MMVC = Model + Module + View + Controller. Modules are first-class units that own a domain (users, billing, cms, …) with their own traits, plugins, interfaces and settings INI. The Registry auto-discovers module configs. Controllers extend `Nibiru\\Adapter\\Controller` and define `pageAction()` and `navigationAction()` (always called) plus optional named actions. Views are Smarty .tpl files in `application/view/templates/`. The CLI is `./nibiru` — `-m` creates modules, `-c` creates controllers, `-mi <env>` runs migrations from `application/settings/config/database/`.
|
||||
|
||||
CONVENTIONS:
|
||||
- Controllers extend `Nibiru\\Adapter\\Controller`.
|
||||
- View::assign(['key' => $value]) passes data to Smarty.
|
||||
- View::forwardTo('/path') redirects.
|
||||
- View::forwardToJsonHeader() makes an action a JSON endpoint.
|
||||
- Form::create() then Form::addInputType…() then Form::addForm() builds a form.
|
||||
- Models live in application/model/, auto-generated from DB tables.
|
||||
- Modules live in application/module/<name>/ with traits/, plugins/, interfaces/, settings/.
|
||||
|
||||
ANSWER STYLE:
|
||||
- Always show concrete code for Nibiru questions, with the file path as a comment header.
|
||||
- Prefer small, self-contained examples over long prose.
|
||||
- Cite the canonical class names (View, Form, Router, Pageination — note the spelling).
|
||||
- Don't recommend Laravel/Symfony idioms. Use Nibiru's singletons, factories, and modules.
|
||||
- Default database driver is `pdo`; switch via `[DATABASE] driver = "psql"|"postgresql"|"mysql"` in the INI.
|
||||
- When in doubt, recommend the CLI: `./nibiru -m foo`, `./nibiru -c bar`.
|
||||
"""
|
||||
|
||||
TEMPLATE """{{ if .System }}<|im_start|>system
|
||||
{{ .System }}<|im_end|>
|
||||
{{ end }}{{ if .Prompt }}<|im_start|>user
|
||||
{{ .Prompt }}<|im_end|>
|
||||
{{ end }}<|im_start|>assistant
|
||||
{{ .Response }}<|im_end|>
|
||||
"""
|
||||
63
application/module/ai/training/README.md
Normal file
63
application/module/ai/training/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Training nibiru-coder
|
||||
|
||||
This folder contains everything needed to register a Nibiru-flavoured chat
|
||||
model on your Ollama server (defaults to `http://localhost:11434`; override
|
||||
via the `OLLAMA_BASE_URL` env var or in `application/module/ai/settings/ai.production.ini`).
|
||||
|
||||
## What the model is
|
||||
|
||||
`nibiru-coder` is a **system-prompt-customised** Qwen 2.5 Coder 14B. It's not
|
||||
a fine-tune in the LoRA sense — it's the same weights as the base model but
|
||||
with a baked-in system prompt that:
|
||||
|
||||
- explains MMVC, modules, the dispatcher, and Nibiru's singletons,
|
||||
- enforces the framework's conventions (`pageAction`, `navigationAction`,
|
||||
`View::assign`, `Form::create`),
|
||||
- pushes the model toward Nibiru-idiomatic answers instead of generic Laravel
|
||||
/ Symfony advice.
|
||||
|
||||
System-prompt customisation runs **instantly** (no GPU training time) and
|
||||
gives ~80 % of the value of a real LoRA at zero training cost. When you have
|
||||
budget for a real LoRA, the
|
||||
[corpus exporter](/en/ai/corpus/) produces the JSONL you'd train on.
|
||||
|
||||
## Build it
|
||||
|
||||
```bash
|
||||
./application/module/ai/training/build.sh # builds nibiru-coder:1.0
|
||||
./application/module/ai/training/build.sh 1.1 # bump tag
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
1. Reads the `Modelfile` next to it.
|
||||
2. POSTs to `${OLLAMA_BASE_URL}/api/create`.
|
||||
3. Runs a smoke-test chat call to confirm the new tag responds.
|
||||
|
||||
After it succeeds, set the model in `application/module/ai/settings/ai.ini`:
|
||||
|
||||
```ini
|
||||
[AI]
|
||||
chat.model = "nibiru-coder:1.0"
|
||||
```
|
||||
|
||||
…and every `\Nibiru\Module\Ai\Ai` instance in your app talks to it.
|
||||
|
||||
## Iterate on the system prompt
|
||||
|
||||
The Modelfile's `SYSTEM """ ... """` block is the lever. Tighten the
|
||||
conventions, add new examples, or add citations to specific framework files.
|
||||
Re-run `build.sh` with a new tag (`1.1`, `1.2`) and A/B against the previous
|
||||
tag in your app.
|
||||
|
||||
## Real LoRA path (when you're ready)
|
||||
|
||||
1. Run `npm run build:corpus` in `docs/` — produces `dist/corpus/chat.jsonl`.
|
||||
2. Use Axolotl / Unsloth / LLaMA-Factory with that JSONL as your sharegpt
|
||||
training set.
|
||||
3. Convert the resulting LoRA to GGUF (`llama.cpp`'s `convert-hf-to-gguf.py`).
|
||||
4. Build an Ollama Modelfile with `FROM ./your-lora.gguf` and re-register
|
||||
as `nibiru-coder:2.0`.
|
||||
|
||||
The framework code doesn't need to change — flip the model tag in
|
||||
`ai.ini` and you're on the new weights.
|
||||
60
application/module/ai/training/build.sh
Executable file
60
application/module/ai/training/build.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Build nibiru-coder on the configured Ollama server.
|
||||
#
|
||||
# Usage:
|
||||
# ./application/module/ai/training/build.sh # default tag :1.0
|
||||
# ./application/module/ai/training/build.sh 1.1 # custom tag
|
||||
# OLLAMA_BASE_URL=https://your.ollama.example ./.../build.sh # remote Ollama
|
||||
#
|
||||
# Prereqs:
|
||||
# - The base model (qwen2.5-coder:14b) is already pulled on the server.
|
||||
# - jq is installed locally.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
OLLAMA_BASE_URL="${OLLAMA_BASE_URL:-http://localhost:11434}"
|
||||
TAG="${1:-1.0}"
|
||||
MODEL_NAME="nibiru-coder:${TAG}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MODELFILE_PATH="${SCRIPT_DIR}/Modelfile"
|
||||
|
||||
if [ ! -f "$MODELFILE_PATH" ]; then
|
||||
echo "Modelfile not found at $MODELFILE_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "jq is required: https://stedolan.github.io/jq/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building $MODEL_NAME on $OLLAMA_BASE_URL …"
|
||||
RESP=$(jq -n \
|
||||
--arg name "$MODEL_NAME" \
|
||||
--rawfile mf "$MODELFILE_PATH" \
|
||||
'{name: $name, modelfile: $mf, stream: false}' \
|
||||
| curl -sS -X POST "${OLLAMA_BASE_URL}/api/create" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @-)
|
||||
|
||||
echo "$RESP" | jq -r '.status // .error // "ok"'
|
||||
|
||||
# Smoke test — make sure it answers.
|
||||
echo
|
||||
echo "Smoke test: 'What does Form::create() do?'"
|
||||
curl -sS "${OLLAMA_BASE_URL}/api/chat" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg m "$MODEL_NAME" '{
|
||||
model: $m,
|
||||
messages: [{role:"user", content:"In one sentence, what does Form::create() do in Nibiru?"}],
|
||||
stream: false,
|
||||
options: {num_predict: 60}
|
||||
}')" | jq -r '.message.content'
|
||||
|
||||
echo
|
||||
echo "Done. Use the model:"
|
||||
echo " curl ${OLLAMA_BASE_URL}/api/chat -d '{\"model\":\"$MODEL_NAME\",\"messages\":[…]}'"
|
||||
echo "or set in your app:"
|
||||
echo " [AI]"
|
||||
echo " chat.model = \"$MODEL_NAME\""
|
||||
85
application/module/ai/training/smoke-test.php
Normal file
85
application/module/ai/training/smoke-test.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
/**
|
||||
* Smoke-test the AI module against the configured Ollama server.
|
||||
* Defaults to localhost:11434; override via OLLAMA_BASE_URL env var.
|
||||
*
|
||||
* Run from the project root:
|
||||
* php application/module/ai/training/smoke-test.php
|
||||
*/
|
||||
|
||||
namespace {
|
||||
require_once __DIR__ . '/../plugins/ollama.php';
|
||||
require_once __DIR__ . '/../plugins/chat.php';
|
||||
require_once __DIR__ . '/../plugins/embed.php';
|
||||
|
||||
use Nibiru\Module\Ai\Plugins\Chat;
|
||||
use Nibiru\Module\Ai\Plugins\Embed;
|
||||
use Nibiru\Module\Ai\Plugins\Ollama;
|
||||
|
||||
// Stand-in config. In real use this is loaded from settings/ai.ini via the
|
||||
// Registry. Override the URL via the OLLAMA_BASE_URL env var when testing
|
||||
// against a non-default Ollama instance.
|
||||
$cfg = (object) [
|
||||
'ollama_base_url' => getenv('OLLAMA_BASE_URL') ?: 'http://localhost:11434',
|
||||
'ollama_timeout' => 60,
|
||||
'ollama_retries' => 1,
|
||||
'chat_model' => getenv('OLLAMA_CHAT_MODEL') ?: 'qwen2.5-coder:14b',
|
||||
'chat_fallback_model' => 'qwen2.5-coder:14b',
|
||||
'chat_system_prompt' => 'You are a Nibiru framework expert. Be concise.',
|
||||
'chat_temperature' => 0.3,
|
||||
'chat_max_tokens' => 200,
|
||||
'embed_model' => getenv('OLLAMA_EMBED_MODEL') ?: 'nomic-embed-text',
|
||||
];
|
||||
|
||||
echo "=== Ollama at ", $cfg->ollama_base_url, " ===\n";
|
||||
$ollama = new Ollama($cfg);
|
||||
try {
|
||||
$models = $ollama->listModels();
|
||||
$names = array_column($models['models'] ?? [], 'name');
|
||||
echo " found ", count($names), " models\n";
|
||||
echo " qwen2.5-coder:14b: ",
|
||||
in_array('qwen2.5-coder:14b', $names, true) ? "yes" : "NO",
|
||||
"\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo " FAILED: ", $e->getMessage(), "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "\n=== Chat::ask ===\n";
|
||||
$chat = new Chat($cfg);
|
||||
try {
|
||||
$reply = $chat->ask('In one sentence, what does Form::create() do in Nibiru?');
|
||||
echo " reply: ", trim($reply), "\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo " FAILED: ", $e->getMessage(), "\n";
|
||||
}
|
||||
|
||||
echo "\n=== Multi-turn chat ===\n";
|
||||
$chat2 = new Chat($cfg);
|
||||
try {
|
||||
$chat2->user('Name three Nibiru singletons.');
|
||||
$r1 = $chat2->complete();
|
||||
echo " r1: ", trim($r1), "\n";
|
||||
$chat2->user('And what does the second one do?');
|
||||
$r2 = $chat2->complete();
|
||||
echo " r2: ", trim($r2), "\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo " FAILED: ", $e->getMessage(), "\n";
|
||||
}
|
||||
|
||||
echo "\n=== Embed::cosine ===\n";
|
||||
try {
|
||||
$embed = new Embed($cfg);
|
||||
$a = $embed->one('controller');
|
||||
$b = $embed->one('module');
|
||||
$c = $embed->one('hummingbird');
|
||||
echo " dim(controller) = ", count($a), "\n";
|
||||
echo " cos(controller, module) = ", number_format(Embed::cosine($a, $b), 3), "\n";
|
||||
echo " cos(controller, hummingbird) = ", number_format(Embed::cosine($a, $c), 3), "\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo " FAILED: ", $e->getMessage(), "\n";
|
||||
echo " (expected if nomic-embed-text isn't pulled)\n";
|
||||
}
|
||||
|
||||
echo "\nSmoke test done.\n";
|
||||
}
|
||||
19
application/module/ai/traits/ai.php
Normal file
19
application/module/ai/traits/ai.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Ai\Traits;
|
||||
|
||||
trait Ai
|
||||
{
|
||||
/**
|
||||
* Read a config value with a default. Honours nested . keys, e.g.
|
||||
* $this->cfg('chat.model', 'qwen2.5-coder:14b')
|
||||
*/
|
||||
protected function cfg(string $key, $default = null)
|
||||
{
|
||||
$cfg = $this->config();
|
||||
if (isset($cfg->$key)) return $cfg->$key;
|
||||
// Allow flat-key INI as written in ai.ini ("chat.model = ..." → "chat.model" key)
|
||||
$varname = str_replace('.', '_', $key);
|
||||
if (isset($cfg->$varname)) return $cfg->$varname;
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
49
application/module/users/interfaces/users.php
Executable file
49
application/module/users/interfaces/users.php
Executable file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Users\Interfaces;
|
||||
/**
|
||||
* @desc this file is for the autoloader to function properly,
|
||||
* you might as well use it as the primary user interface for your
|
||||
* application
|
||||
* Created by PhpStorm.
|
||||
* User: kasdorf
|
||||
* Date: 28.08.18
|
||||
* Time: 11:21
|
||||
*/
|
||||
|
||||
interface Users
|
||||
{
|
||||
const CONFIG_MODULE_NAME = 'users';
|
||||
const FORM_CREATE_USER = [
|
||||
[ 'visibleName' => 'Benutzername', 'valueName' => 'user_login', 'icon' => 'lock' ],
|
||||
[ 'visibleName' => 'Vorname', 'valueName' => 'user_firstname', 'icon' => 'user' ],
|
||||
[ 'visibleName' => 'Nachname', 'valueName' => 'user_lastname', 'icon' => 'user' ],
|
||||
];
|
||||
const FORM_CREATE_PASSWORD = [
|
||||
[ 'visibleName' => 'Passwort', 'valueName' => 'user_pass', 'icon' => 'lock' ],
|
||||
[ 'visibleName' => 'Passwort wiederholen', 'valueName' => 'password_repeat', 'icon' => 'lock' ],
|
||||
];
|
||||
|
||||
const FORM_CREATE_ACCOUNT = [
|
||||
[ 'visibleName' => 'Konto Name', 'valueName' => 'user_account_name', 'icon' => 'wallet' ],
|
||||
[ 'visibleName' => 'Konto Email', 'valueName' => 'user_account_email', 'icon' => 'email' ]
|
||||
];
|
||||
const REGISTRY_USER_KEYS = [
|
||||
'user_firstname',
|
||||
'user_lastname',
|
||||
'user_login',
|
||||
'user_active',
|
||||
'user_acl_id',
|
||||
'user_account_name',
|
||||
'user_account_active',
|
||||
'user_account_email',
|
||||
'user_pass'
|
||||
];
|
||||
const FORM_CHECKBOX_SWITCH = [
|
||||
'on' => true,
|
||||
'off' => false
|
||||
];
|
||||
const FORM_CHECKBOX_ON = "on";
|
||||
const FORM_CHECKBOX_OFF = "off";
|
||||
const USER_ID_ENCRYPTION = 'user_id_encryption';
|
||||
const USER_ID_ENCRYPTION_KEY = 'user_id_encryption_key';
|
||||
}
|
||||
72
application/module/users/plugins/acl.php
Executable file
72
application/module/users/plugins/acl.php
Executable file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Users\Plugins;
|
||||
/**
|
||||
* Class Acl
|
||||
* @project Lagerverwaltung
|
||||
* @desc This is the ACL plugin for the users module. It checks if the user is logged in or not, and also loads the corresponding
|
||||
* user role into the session.
|
||||
* @author stephan - Maschinen Stockert Großhandels GmbH
|
||||
* @date 26.01.23
|
||||
* @time 11:38
|
||||
* @package Nibiru\Module\Users\Plugins
|
||||
*/
|
||||
use Nibiru\View;
|
||||
use Nibiru\Factory\Db;
|
||||
class Acl extends User
|
||||
{
|
||||
/**
|
||||
* @desc Acl constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc checks if the users is logged into the system, if yes nothing happens,
|
||||
* if no it redirects to the login page.
|
||||
* @return void
|
||||
*/
|
||||
public function init(): void
|
||||
{
|
||||
if(!$this->validate())
|
||||
{
|
||||
View::forwardTo('/users/login');
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->loadUserRole();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc checks if the user is logged in, if yes it loads the corresponding user role into the session.
|
||||
* @return void
|
||||
* [auth] => Array
|
||||
(
|
||||
[session_id] => SESSION_ID
|
||||
[user_id] => USER_ID
|
||||
[login] => USER_LOGIN
|
||||
[role] => ACL_ROLE
|
||||
)
|
||||
*/
|
||||
public function loadUserRole(): void
|
||||
{
|
||||
if(User::validate())
|
||||
{
|
||||
$userRole = User::getRoleByLoggedInUser();
|
||||
$_SESSION['auth']['role'] = $userRole['acl_role'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will load the acl roles from the database and return them as an array.
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function loadAclRoles(): array
|
||||
{
|
||||
$acl = Db::loadModel('WarehouseLoach\Acl');
|
||||
return $acl->loadTableAsArray();
|
||||
}
|
||||
}
|
||||
516
application/module/users/plugins/user.php
Executable file
516
application/module/users/plugins/user.php
Executable file
@@ -0,0 +1,516 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Users\Plugins;
|
||||
/**
|
||||
* Class Users
|
||||
* @package Nibiru\Module\Users\Plugins
|
||||
* @author: Stephan Kasdorf
|
||||
* @date: 04.09.20
|
||||
* @copyright: 2020 Nibiru Framework, you may copy the code,
|
||||
* but have to inform the author about where it
|
||||
* is used. So happy copying.
|
||||
* @licence: BSD 4-Old License
|
||||
*/
|
||||
use Nibiru\Auth;
|
||||
use Nibiru\Controller;
|
||||
use Nibiru\Config;
|
||||
use Nibiru\Module\Error\Plugins\Message;
|
||||
use Nibiru\Module\Memcached\Plugins\Memcached;
|
||||
use Nibiru\Module\Users\Traits\UserForm;
|
||||
use Nibiru\Module\Users\Users;
|
||||
use Nibiru\Factory\Db;
|
||||
use Nibiru\IView;
|
||||
use Nibiru\Pdo;
|
||||
use Nibiru\Model\WarehouseLoach;
|
||||
|
||||
class User extends Users
|
||||
{
|
||||
use UserForm;
|
||||
//check from auth in order to determine if user may login to the system
|
||||
private bool $_auth;
|
||||
private object $userRegistry;
|
||||
private string $status;
|
||||
private int $userId;
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc authenticates user in the system and stores the session in order to verify the
|
||||
* the login, then set the session and return true if the user is logged in.
|
||||
* Will update the timeanddate_time field in the timeanddate table with the current time.
|
||||
* @return bool
|
||||
*/
|
||||
public function validate( ): bool
|
||||
{
|
||||
if(array_key_exists('login', Controller::getInstance()->getRequest('', true)) && array_key_exists('password', Controller::getInstance()->getRequest('', true)))
|
||||
{
|
||||
$this->_auth = Auth::getInstance()->auth(
|
||||
Controller::getInstance()->getRequest('login'),
|
||||
Controller::getInstance()->getRequest('password'));
|
||||
if($this->_auth)
|
||||
{
|
||||
$timeanddateToUser = Db::loadModel('WarehouseLoach\timeanddateToUser')->selectDatasetByFieldWhere(['name' => 'user_id', 'value' => Controller::getInstance()->getSession('auth')['user_id']]);
|
||||
$timeanddate_id = array_shift($timeanddateToUser)['timeanddate_id'];
|
||||
Db::loadModel('WarehouseLoach\timeanddate')->updateRowByFieldWhere('timeanddate_id', $timeanddate_id, 'timeanddate_time', date('Y-m-d H:i:s'));
|
||||
$this->setUserIdToSessionCookie();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->_auth = $this->isAuthorized();
|
||||
}
|
||||
return $this->_auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will return true if the user is authorized otherwise false
|
||||
* @return bool
|
||||
*/
|
||||
public function isAuthorized(): bool
|
||||
{
|
||||
if(array_key_exists('auth', Controller::getInstance()->getSession('', true)))
|
||||
{
|
||||
if(array_key_exists('login', Controller::getInstance()->getSession('auth')))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will log out the user and destroy the session
|
||||
* @return bool
|
||||
*/
|
||||
public function revokeAuthorized(): bool
|
||||
{
|
||||
setcookie('user_id', '', [
|
||||
'expires' => time() - 3600, // set the cookie to expire one hour ago
|
||||
'path' => '/',
|
||||
'secure' => false,
|
||||
'httponly' => true,
|
||||
]);
|
||||
Memcached::init()->runServer()->delete($_SESSION['auth']['user_id']);
|
||||
unset($_SESSION['auth']);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will update the password of the user, check if the value $user_id is set, if not it will use the current logged in user.
|
||||
* @param int $user_id
|
||||
* @param string $password
|
||||
* @return bool
|
||||
*/
|
||||
public function updatePassword(string $password, int $user_id): bool
|
||||
{
|
||||
if($user_id == 0)
|
||||
{
|
||||
$user_id = $_SESSION['auth']['user_id'];
|
||||
}
|
||||
return Pdo::query('UPDATE user SET user_pass = DES_ENCRYPT("'.$password.'", "'.Config::getInstance()->getConfig()[IView::NIBIRU_SECURITY]["password_hash"].'") WHERE user_id = '.$user_id.';');
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will return true if the standard user is present otherwise false
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function checkForStandardUser(): bool
|
||||
{
|
||||
$userTable = Db::loadModel('WarehouseLoach\user')->loadTableAsArray();
|
||||
if(sizeof($userTable)==0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
$standardUser = array_search(self::getUsersRegistry()->user_login, array_column($userTable, 'user_login'));
|
||||
if($standardUser === false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will check if the user exists in the database
|
||||
* @param string $user_login
|
||||
* @return bool
|
||||
*/
|
||||
public function checkIfUserExists(string $user_login): bool
|
||||
{
|
||||
$userTable = Db::loadModel('WarehouseLoach\user')->loadTableAsArray();
|
||||
$user = array_search($user_login, array_column($userTable, 'user_login'));
|
||||
if($user === false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc create an object that loads the userdata from the registry, if the user data is not passed by the method
|
||||
* parameter. Also prepares the user data for user updates in the database.
|
||||
* @param array $user_data
|
||||
* @param bool $isUpdate
|
||||
* @return bool
|
||||
*/
|
||||
public function loadUser( array $user_data = [], bool $isUpdate = false ): bool
|
||||
{
|
||||
$this->userRegistry = new \stdClass();
|
||||
if(sizeof($user_data) == 0)
|
||||
{
|
||||
foreach(self::REGISTRY_USER_KEYS as $key)
|
||||
{
|
||||
$this->userRegistry->$key = self::getUsersRegistry()->$key;
|
||||
}
|
||||
return Message::loadInfo('User loaded', ['info' => json_decode(json_encode($this->userRegistry), true)]);
|
||||
}
|
||||
else
|
||||
{
|
||||
try {
|
||||
if(!$this->checkIfUserExists($user_data['user_login']) || $isUpdate)
|
||||
{
|
||||
foreach(self::REGISTRY_USER_KEYS as $key)
|
||||
{
|
||||
if($user_data[$key] == self::FORM_CHECKBOX_ON || $user_data[$key] == self::FORM_CHECKBOX_OFF)
|
||||
{
|
||||
$user_data[$key] = self::FORM_CHECKBOX_SWITCH[$user_data[$key]];
|
||||
}
|
||||
$this->userRegistry->$key = $user_data[$key];
|
||||
}
|
||||
if($isUpdate)
|
||||
{
|
||||
$this->userRegistry->user_id = $user_data['user_id'];
|
||||
}
|
||||
return Message::loadInfo('User loaded', ['info' => json_decode(json_encode($this->userRegistry), true)]);
|
||||
} else
|
||||
{
|
||||
throw new \Exception('User already exists');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return Message::loadError($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will create the initial user for the system, if no user is setup.
|
||||
* Can be found in the application/module/users/settings/users.ini
|
||||
* file.
|
||||
* @return int|bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function createStandardUser(): int|bool
|
||||
{
|
||||
try {
|
||||
Db::loadModel('WarehouseLoach\user')->insertArrayIntoTable([
|
||||
'user_firstname' => $this->userRegistry->user_firstname,
|
||||
'user_lastname' => $this->userRegistry->user_lastname,
|
||||
'user_login' => $this->userRegistry->user_login,
|
||||
'user_account_active' => $this->userRegistry->user_active
|
||||
]);
|
||||
$lastUserId = Db::loadModel('WarehouseLoach\user')->lastInsertId();
|
||||
|
||||
Db::loadModel('WarehouseLoach\timeanddate')->insertArrayIntoTable([
|
||||
]);
|
||||
$lastTimeanddateId = Db::loadModel('WarehouseLoach\timeanddate')->lastInsertId();
|
||||
Db::loadModel('WarehouseLoach\timeanddateToUser')->insertArrayIntoTable([
|
||||
'timeanddate_id' => $lastTimeanddateId,
|
||||
'user_id' => $lastUserId
|
||||
]);
|
||||
|
||||
Db::loadModel('WarehouseLoach\userToAcl')->insertArrayIntoTable([
|
||||
'user_id' => $lastUserId,
|
||||
'acl_id' => $this->userRegistry->user_acl_id
|
||||
]);
|
||||
|
||||
Db::loadModel('WarehouseLoach\account')->insertArrayIntoTable([
|
||||
'account_name' => $this->userRegistry->user_account_name,
|
||||
'account_email' => $this->userRegistry->user_account_email,
|
||||
'account_active' => $this->userRegistry->user_account_active
|
||||
]);
|
||||
$lastAccountId = Db::loadModel('WarehouseLoach\account')->lastInsertId();
|
||||
Db::loadModel('WarehouseLoach\timeanddate')->insertArrayIntoTable([
|
||||
]);
|
||||
$lastTimeanddateId = Db::loadModel('WarehouseLoach\timeanddate')->lastInsertId();
|
||||
Db::loadModel('WarehouseLoach\timeanddateToAccount')->insertArrayIntoTable([
|
||||
'account_id' => $lastAccountId,
|
||||
'timeanddate_id' => $lastTimeanddateId
|
||||
]);
|
||||
Db::loadModel('WarehouseLoach\userToAccount')->insertArrayIntoTable([
|
||||
'account_id' => $lastAccountId,
|
||||
'user_id' => $lastUserId
|
||||
]);
|
||||
$setPassword = Pdo::query('UPDATE user SET user_pass = DES_ENCRYPT("'.$this->userRegistry->user_pass.'", "'.Config::getInstance()->getConfig()[IView::NIBIRU_SECURITY]["password_hash"].'") WHERE user_id = '.$lastUserId.';');
|
||||
if(empty($setPassword))
|
||||
{
|
||||
return $lastUserId;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new \Exception('Could not set password for user '.$lastUserId . ' with exception value: ' . $setPassword . '!');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return Message::loadError($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will return the role of the logged in user
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getRoleByLoggedInUser(): array
|
||||
{
|
||||
$userToAcl = Db::loadModel('WarehouseLoach\userToAcl')->selectRowByFieldWhere([
|
||||
'field' => 'user_id',
|
||||
'value' => $_SESSION['auth']['user_id']
|
||||
]);
|
||||
return Db::loadModel('WarehouseLoach\acl')->selectRowByFieldWhere([
|
||||
'field' => 'acl_id',
|
||||
'value' => $userToAcl['acl_id']
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will load all users from the database and return them as an array
|
||||
* @return array
|
||||
*/
|
||||
public function loadUsers(): array
|
||||
{
|
||||
return Pdo::queryString('select u.user_id as uid,
|
||||
u.user_firstname as vorname,
|
||||
u.user_lastname as nachname,
|
||||
u.user_login as login,
|
||||
u.user_account_active as aktiv,
|
||||
a.acl_role as berechtigung,
|
||||
tad.timeanddate_date as datum_benutzer,
|
||||
tad.timeanddate_time as zeit_benutzer,
|
||||
ac.account_name as konto,
|
||||
ac.account_email as konto_email,
|
||||
ac.account_active as konto_aktiv,
|
||||
tadc.timeanddate_date as datum_konto,
|
||||
tadc.timeanddate_time as zeit_konto
|
||||
from warehouse_loach.user as u
|
||||
join warehouse_loach.user_to_acl as uta on u.user_id = uta.user_id
|
||||
join warehouse_loach.acl as a on uta.acl_id = a.acl_id
|
||||
join warehouse_loach.timeanddate_to_user as ttu on u.user_id = ttu.user_id
|
||||
join warehouse_loach.timeanddate as tad on ttu.timeanddate_id = tad.timeanddate_id
|
||||
join user_to_account uta2 on u.user_id = uta2.user_id
|
||||
join account ac on uta2.account_id = ac.account_id
|
||||
join timeanddate_to_account tta on ac.account_id = tta.account_id
|
||||
join timeanddate tadc on tta.timeanddate_id = tadc.timeanddate_id;', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will load a user by the given id
|
||||
* @param int $user_id
|
||||
* @return array
|
||||
*/
|
||||
public function loadUserById( int $user_id ): array
|
||||
{
|
||||
$user = Pdo::queryString('select u.user_id as uid,
|
||||
u.user_firstname as user_firstname,
|
||||
u.user_lastname as user_lastname,
|
||||
u.user_login as user_login,
|
||||
DES_DECRYPT(u.user_pass, "'.Config::getInstance()->getConfig()[IView::NIBIRU_SECURITY]["password_hash"].'") as user_pass,
|
||||
u.user_account_active as user_account_active,
|
||||
a.acl_id as user_role_id,
|
||||
ac.account_name as user_account_name,
|
||||
ac.account_active as user_account_active,
|
||||
ac.account_email as user_account_email
|
||||
from warehouse_loach.user as u
|
||||
join warehouse_loach.user_to_acl as uta on u.user_id = uta.user_id
|
||||
join warehouse_loach.acl as a on uta.acl_id = a.acl_id
|
||||
join warehouse_loach.timeanddate_to_user as ttu on u.user_id = ttu.user_id
|
||||
join warehouse_loach.timeanddate as tad on ttu.timeanddate_id = tad.timeanddate_id
|
||||
join user_to_account uta2 on u.user_id = uta2.user_id
|
||||
join account ac on uta2.account_id = ac.account_id
|
||||
join timeanddate_to_account tta on ac.account_id = tta.account_id
|
||||
join timeanddate tadc on tta.timeanddate_id = tadc.timeanddate_id
|
||||
where u.user_id = '.$user_id.';', true);
|
||||
$user = array_shift($user);
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will delete a user by the given id
|
||||
* @param int $user_id
|
||||
* @return bool
|
||||
*/
|
||||
public function deleteUserById( int $user_id ): bool
|
||||
{
|
||||
try {
|
||||
$user = $this->loadUserById($user_id);
|
||||
if($user['user_role_id'] == 1)
|
||||
{
|
||||
unset($user);
|
||||
throw new \Exception('You can not delete the superuser!');
|
||||
}
|
||||
else
|
||||
{
|
||||
$userToAccount = Pdo::queryString('SELECT account_id FROM user_to_account WHERE user_id = '.$user_id.';', true);
|
||||
$account_id = array_shift($userToAccount)["account_id"];
|
||||
$timeanddateToUser = Pdo::queryString('SELECT timeanddate_id FROM timeanddate_to_user WHERE user_id = '.$user_id.';', true);
|
||||
$timeanddate_id = array_shift($timeanddateToUser)["timeanddate_id"];
|
||||
Pdo::query('DELETE FROM user_to_acl WHERE user_id = '.$user_id.';');
|
||||
Pdo::query('DELETE FROM user_to_account WHERE user_id = '.$user_id.';');
|
||||
Pdo::query('DELETE FROM timeanddate_to_user WHERE user_id = '.$user_id.';');
|
||||
Pdo::query('DELETE FROM timeanddate_to_account WHERE account_id = '.$account_id.';');
|
||||
Pdo::query('DELETE FROM user WHERE user_id = '.$user_id.';');
|
||||
Pdo::query('DELETE FROM account WHERE account_id = '.$account_id.';');
|
||||
Pdo::query('DELETE FROM timeanddate WHERE timeanddate_id = '.$timeanddate_id.';');
|
||||
unset($user['user_pass']);
|
||||
return Message::loadInfo("User deleted successfully", $user);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return Message::loadError($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will set the user active or inactive
|
||||
* @param int $user_id
|
||||
* @param int $active
|
||||
* @return bool
|
||||
*/
|
||||
public function setUserActive( int $user_id, int $active ): bool
|
||||
{
|
||||
try {
|
||||
if($active)
|
||||
{
|
||||
$state = '<span class="badge badge-success">Aktiv</span>';
|
||||
}
|
||||
else
|
||||
{
|
||||
$state = '<span class="badge badge-danger">Inaktiv</span>';
|
||||
}
|
||||
$user = $this->loadUserById($user_id);
|
||||
if($user['user_role_id'] == 1)
|
||||
{
|
||||
unset($user);
|
||||
throw new \Exception('You can not deactivate the superuser!');
|
||||
}
|
||||
else
|
||||
{
|
||||
Db::loadModel('WarehouseLoach\User')->updateRowByFieldWhere('user_id', $user_id, 'user_account_active', $active);
|
||||
unset($user['user_pass']);
|
||||
return Message::loadInfo("User updated active state successfully: $state", $user);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return Message::loadError($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will update the user by the given user array
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function updateUser(): bool
|
||||
{
|
||||
try {
|
||||
$user = (array) $this->userRegistry;
|
||||
$userString = '';
|
||||
foreach(WarehouseLoach\User::TABLE['fields'] as $field)
|
||||
{
|
||||
if(array_key_exists($field, $user))
|
||||
{
|
||||
if($field == 'user_pass')
|
||||
{
|
||||
$userString .= $field.' = DES_ENCRYPT("'.$user[$field].'", "'.Config::getInstance()->getConfig()[IView::NIBIRU_SECURITY]["password_hash"].'"), ';
|
||||
}
|
||||
else
|
||||
{
|
||||
if($field != 'user_id')
|
||||
{
|
||||
$userString .= $field.' = "'.$user[$field].'", ';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Pdo::query('UPDATE user SET '.substr($userString, 0, -2).' WHERE user_id = '.$user['user_id'].';');
|
||||
$userToAccount = Db::loadModel('WarehouseLoach\userToAccount')->selectDatasetByFieldWhere(['name' => 'user_id', 'value' => $user['user_id']]);
|
||||
$account_id = array_shift($userToAccount)['account_id'];
|
||||
if(isset($account_id))
|
||||
{
|
||||
$accountString = '';
|
||||
foreach(WarehouseLoach\Account::TABLE['fields'] as $field)
|
||||
{
|
||||
foreach($user as $key => $value)
|
||||
{
|
||||
if(strstr($key, $field))
|
||||
{
|
||||
$accountString .= $field.' = "'.$user[$key].'", ';
|
||||
}
|
||||
}
|
||||
}
|
||||
Pdo::query('UPDATE account SET '.substr($accountString, 0, -2).' WHERE account_id = '.$account_id.';');
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new \Exception('Did not get valid Account ID, the database could be corrupted for that user! user_id: ' . $user['user_id']);
|
||||
}
|
||||
Db::loadModel('WarehouseLoach\userToAcl')->updateRowByFieldWhere('user_id', $user['user_id'], 'acl_id', $user['user_acl_id']);
|
||||
} catch (\Exception $e) {
|
||||
Message::loadError($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will set the user_id in a cookie, in order to find the memcached session
|
||||
* @return void
|
||||
*/
|
||||
public function setUserIdToSessionCookie(): void
|
||||
{
|
||||
if(!array_key_exists('user_id', $_COOKIE) && array_key_exists('auth', $_SESSION))
|
||||
{
|
||||
$ivLength = openssl_cipher_iv_length($this->getUsersRegistry()->{self::USER_ID_ENCRYPTION});
|
||||
$iv = openssl_random_pseudo_bytes($ivLength);
|
||||
$encryptedUserId = openssl_encrypt($_SESSION['auth']['user_id'], $this->getUsersRegistry()->{self::USER_ID_ENCRYPTION}, $this->getUsersRegistry()->{self::USER_ID_ENCRYPTION_KEY}, 0, $iv);
|
||||
$cookieValue = base64_encode($encryptedUserId . '::' . $iv);
|
||||
setcookie('user_id', $cookieValue, [
|
||||
'expires' => time() + (86400 * 30),
|
||||
'path' => '/',
|
||||
'secure' => false, // only send cookie over https
|
||||
'httponly' => true, // make the cookie inaccessible to javascript
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will get the user_id from the cookie
|
||||
* @return int
|
||||
*/
|
||||
public function getUserIdFromSessionCookie(): int
|
||||
{
|
||||
if(array_key_exists('user_id', $_COOKIE))
|
||||
{
|
||||
list($encryptedUserId, $iv) = explode('::', base64_decode($_COOKIE['user_id']), 2);
|
||||
$decryptedUserId = openssl_decrypt($encryptedUserId, $this->getUsersRegistry()->{self::USER_ID_ENCRYPTION}, $this->getUsersRegistry()->{self::USER_ID_ENCRYPTION_KEY}, 0, $iv);
|
||||
return $decryptedUserId;
|
||||
}
|
||||
else
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
application/module/users/settings/users.ini
Executable file
71
application/module/users/settings/users.ini
Executable file
@@ -0,0 +1,71 @@
|
||||
[USERS]
|
||||
name = "users"
|
||||
path = "/application/module/users"
|
||||
is_api = false
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;Start User when no user is part of the system
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
user_firstname = "{user_firstname}"
|
||||
user_lastname = "{user_lastname}"
|
||||
user_pass = "{GENERATE_PASSWORD}"
|
||||
user_login = "{user_login}"
|
||||
user_active = true
|
||||
user_account_name = "admin"
|
||||
user_account_active = true
|
||||
user_account_email = "{user_account_email}"
|
||||
user_acl_id = 1
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;User Cookie // Session handling
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
user_id_encryption = "AES-256-CBC"
|
||||
user_id_encryption_key = "B4D4F6H8J0K2L4N6"
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;Login css files
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
users_login_css[] = "/public/css/vendors_css.css"
|
||||
users_login_css[] = "/public/css/style.css"
|
||||
users_login_css[] = "/public/css/skin_color.css"
|
||||
users_login_js[] = "/public/js/vendors.min.js"
|
||||
users_login_js[] = "/public/js/pages/chat-popup.js"
|
||||
users_login_js[] = "/public/assets/icons/feather-icons/feather.min.js"
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;User listing css/js files
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
users_user-list_css[] = "/public/css/vendors_css.css"
|
||||
users_user-list_css[] = "/public/css/style.css"
|
||||
users_user-list_css[] = "/public/css/skin_color.css"
|
||||
users_user-list_js[] = "/public/js/vendors.min.js"
|
||||
users_user-list_js[] = "/public/js/pages/chat-popup.js"
|
||||
users_user-list_js[] = "/public/assets/icons/feather-icons/feather.min.js"
|
||||
users_user-list_js[] = "/public/assets/vendor_components/perfect-scrollbar-master/perfect-scrollbar.jquery.min.js"
|
||||
users_user-list_js[] = "/public/assets/vendor_components/datatable/datatables.min.js"
|
||||
users_user-list_js[] = "/public/js/template.js"
|
||||
users_user-list_js[] = "/public/js/pages/data-table.js"
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;User new css/js files
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
users_user-new_css[] = "/public/css/vendors_css.css"
|
||||
users_user-new_css[] = "/public/css/style.css"
|
||||
users_user-new_css[] = "/public/css/skin_color.css"
|
||||
users_user-new_js[] = "/public/js/vendors.min.js"
|
||||
users_user-new_js[] = "/public/js/pages/chat-popup.js"
|
||||
users_user-new_js[] = "/public/assets/icons/feather-icons/feather.min.js"
|
||||
users_user-new_js[] = "/public/assets/vendor_components/jquery-toast-plugin-master/src/jquery.toast.js"
|
||||
users_user-new_js[] = "/public/js/template.js"
|
||||
users_user-new_js[] = "/public/js/pages/toastr.js"
|
||||
users_user-new_js[] = "/public/js/pages/notification.js"
|
||||
users_user-new_js[] = "/public/js/pages/users/new-user.js"
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;User edit css/js files
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
users_user-edit_css[] = "/public/css/vendors_css.css"
|
||||
users_user-edit_css[] = "/public/css/style.css"
|
||||
users_user-edit_css[] = "/public/css/skin_color.css"
|
||||
users_user-edit_js[] = "/public/js/vendors.min.js"
|
||||
users_user-edit_js[] = "/public/js/pages/chat-popup.js"
|
||||
users_user-edit_js[] = "/public/assets/icons/feather-icons/feather.min.js"
|
||||
users_user-edit_js[] = "/public/assets/vendor_components/jquery-toast-plugin-master/src/jquery.toast.js"
|
||||
users_user-edit_js[] = "/public/js/template.js"
|
||||
users_user-edit_js[] = "/public/js/pages/toastr.js"
|
||||
users_user-edit_js[] = "/public/js/pages/notification.js"
|
||||
users_user-edit_js[] = "/public/js/pages/users/edit-user.js"
|
||||
359
application/module/users/traits/userForm.php
Executable file
359
application/module/users/traits/userForm.php
Executable file
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Users\Traits;
|
||||
/**
|
||||
* Trait UserForm
|
||||
* @project src
|
||||
* @desc This is a PHP trait file, please specify the use
|
||||
* @author stephan - Maschinen Stockert Großhandels GmbH
|
||||
* @date 19.04.23
|
||||
* @time 15:10
|
||||
* @package ${PACKAGE}
|
||||
*/
|
||||
use Nibiru\Factory\Form;
|
||||
use Nibiru\Module\Users\Plugins\Acl;
|
||||
trait UserForm
|
||||
{
|
||||
public static function userForm(string $action = '', array $user = [])
|
||||
{
|
||||
$acl = new Acl();
|
||||
Form::create();
|
||||
self::openHalfWidthElement('Benutzer Informationen');
|
||||
self::openInnerHalfWithElement();
|
||||
foreach(self::FORM_CREATE_USER as $entry)
|
||||
{
|
||||
if(sizeof($user) > 0)
|
||||
{
|
||||
self::addTextElement($entry['visibleName'], $entry['valueName'], $entry['icon'], $user[$entry['valueName']]);
|
||||
}
|
||||
else
|
||||
{
|
||||
self::addTextElement($entry['visibleName'], $entry['valueName'], $entry['icon']);
|
||||
}
|
||||
}
|
||||
foreach(self::FORM_CREATE_PASSWORD as $entry)
|
||||
{
|
||||
if(sizeof($user) > 0)
|
||||
{
|
||||
self::addPasswordElement($entry['visibleName'], $entry['valueName'], $entry['icon'], $user['user_pass']);
|
||||
}
|
||||
else
|
||||
{
|
||||
self::addPasswordElement($entry['visibleName'], $entry['valueName'], $entry['icon']);
|
||||
}
|
||||
}
|
||||
self::closeHalfWidthElement();
|
||||
self::closeHalfWidthElement();
|
||||
self::openHalfWidthElement('Konto Informationen');
|
||||
self::openInnerHalfWithElement();
|
||||
foreach(self::FORM_CREATE_ACCOUNT as $entry)
|
||||
{
|
||||
if(sizeof($user) > 0)
|
||||
{
|
||||
self::addTextElement($entry['visibleName'], $entry['valueName'], $entry['icon'], $user[$entry['valueName']]);
|
||||
}
|
||||
else
|
||||
{
|
||||
self::addTextElement($entry['visibleName'], $entry['valueName'], $entry['icon']);
|
||||
}
|
||||
}
|
||||
|
||||
if(sizeof($user) > 0)
|
||||
{
|
||||
self::addSelectDropdown($acl->loadAclRoles(), 'lock', $user['user_role_id']);
|
||||
self::addActiveCheckbox('Konto aktivieren', $user['user_account_active']);
|
||||
}
|
||||
else
|
||||
{
|
||||
self::addSelectDropdown($acl->loadAclRoles(), 'lock');
|
||||
self::addActiveCheckbox('Konto aktivieren', true);
|
||||
}
|
||||
self::closeHalfWidthElement();
|
||||
if(sizeof($user) > 0)
|
||||
{
|
||||
self::addHiddenElementEditUser($user);
|
||||
self::addSubmitFormButton('Speichern', 'save-alt');
|
||||
}
|
||||
else
|
||||
{
|
||||
self::addHiddenElementNewUser();
|
||||
self::addSubmitFormButton('Erstellen', 'save-alt');
|
||||
}
|
||||
|
||||
self::closeHalfWidthElement();
|
||||
return Form::addForm([
|
||||
'name' => 'userForm',
|
||||
'action' => $action,
|
||||
'class' => 'row',
|
||||
'method' => 'post',
|
||||
'target' => '_self'
|
||||
]);
|
||||
}
|
||||
private static function addHiddenElementNewUser()
|
||||
{
|
||||
Form::addTypeHidden([
|
||||
'name' => 'user_new',
|
||||
'value' => 1
|
||||
]);
|
||||
Form::addTypeHidden([
|
||||
'name' => 'user_active',
|
||||
'value' => 1
|
||||
]);
|
||||
}
|
||||
private static function addHiddenElementEditUser(array $user = [])
|
||||
{
|
||||
Form::addTypeHidden([
|
||||
'name' => 'user_edit',
|
||||
'value' => 1
|
||||
]);
|
||||
Form::addTypeHidden([
|
||||
'name' => 'user_active',
|
||||
'value' => $user['user_account_active']
|
||||
]);
|
||||
Form::addTypeHidden([
|
||||
'name' => 'user_id',
|
||||
'value' => $user['uid']
|
||||
]);
|
||||
}
|
||||
private static function addSubmitFormButton(string $buttonText = '', string $icon = '')
|
||||
{
|
||||
Form::addOpenDiv([
|
||||
'class' => 'box-footer text-end',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addTypeButton([
|
||||
'class' => 'btn btn-primary',
|
||||
'type' => 'submit',
|
||||
'value' => '<i class="ti-'.$icon.'"></i> '.$buttonText
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
}
|
||||
/**
|
||||
* @desc will add a text element to the form
|
||||
* @param string $visibleName
|
||||
* @param string $valueName
|
||||
* @param string $icon
|
||||
* @return void
|
||||
*/
|
||||
private static function addTextElement(string $visibleName = '', string $valueName = '', string $icon = '', string $value = '')
|
||||
{
|
||||
Form::addTypeLabel([
|
||||
'class' => 'form-label',
|
||||
'for' => $valueName,
|
||||
'value' => $visibleName
|
||||
]);
|
||||
Form::addOpenDiv([
|
||||
'class' => 'input-group mb-3',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenAny([
|
||||
'any' => 'span',
|
||||
'class' => 'input-group-text',
|
||||
'value' => '<i class="ti-'.$icon.'"></i>'
|
||||
]);
|
||||
Form::addCloseAny([
|
||||
'any' => 'span'
|
||||
]);
|
||||
if($value!="")
|
||||
{
|
||||
Form::addInputTypeText([
|
||||
'class' => 'form-control',
|
||||
'name' => $valueName,
|
||||
'placeholder' => $visibleName,
|
||||
'value' => $value
|
||||
]);
|
||||
}
|
||||
else
|
||||
{
|
||||
Form::addInputTypeText([
|
||||
'class' => 'form-control',
|
||||
'name' => $valueName,
|
||||
'placeholder' => $visibleName
|
||||
]);
|
||||
}
|
||||
|
||||
Form::addCloseDiv();
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will add a select dropdown to the form
|
||||
* @param array $options
|
||||
* @param string $icon
|
||||
* @return void
|
||||
*/
|
||||
private static function addSelectDropdown( array $options = [], string $icon = '', int $selected = 0 )
|
||||
{
|
||||
Form::addTypeLabel([
|
||||
'for' => 'source-api',
|
||||
'class' => 'form-label',
|
||||
'value' => 'Rolle auswählen:'
|
||||
]);
|
||||
Form::addOpenDiv([
|
||||
'class' => 'input-group mb-3',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenAny([
|
||||
'any' => 'span',
|
||||
'class' => 'input-group-text',
|
||||
'value' => '<i class="ti-'.$icon.'"></i>'
|
||||
]);
|
||||
Form::addCloseAny([
|
||||
'any' => 'span'
|
||||
]);
|
||||
foreach($options as $option)
|
||||
{
|
||||
if($selected == $option['acl_id'])
|
||||
{
|
||||
Form::addSelectOption([
|
||||
'context' => $option['acl_role'],
|
||||
'value' => $option['acl_id'],
|
||||
'selected' => 'selected'
|
||||
]);
|
||||
}
|
||||
else
|
||||
{
|
||||
Form::addSelectOption([
|
||||
'context' => $option['acl_role'],
|
||||
'value' => $option['acl_id']
|
||||
]);
|
||||
}
|
||||
}
|
||||
Form::addSelect([
|
||||
'name' => 'user_acl_id',
|
||||
'class' => 'form-control select2'
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will add a password element to the form
|
||||
* @param string $visibleName
|
||||
* @param string $valueName
|
||||
* @param string $icon
|
||||
* @return void
|
||||
*/
|
||||
private static function addPasswordElement(string $visibleName = '', string $valueName = '', string $icon = '', string $value = '')
|
||||
{
|
||||
Form::addTypeLabel([
|
||||
'class' => 'form-label',
|
||||
'for' => $valueName,
|
||||
'value' => $visibleName
|
||||
]);
|
||||
Form::addOpenDiv([
|
||||
'class' => 'input-group mb-3',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenAny([
|
||||
'any' => 'span',
|
||||
'class' => 'input-group-text',
|
||||
'value' => '<i class="ti-'.$icon.'"></i>'
|
||||
]);
|
||||
Form::addCloseAny([
|
||||
'any' => 'span'
|
||||
]);
|
||||
if($value!="")
|
||||
{
|
||||
Form::addInputTypePassword([
|
||||
'class' => 'form-control',
|
||||
'name' => $valueName,
|
||||
'placeholder' => $visibleName,
|
||||
'value' => $value
|
||||
]);
|
||||
}
|
||||
else
|
||||
{
|
||||
Form::addInputTypePassword([
|
||||
'class' => 'form-control',
|
||||
'name' => $valueName,
|
||||
'placeholder' => $visibleName
|
||||
]);
|
||||
}
|
||||
Form::addCloseDiv();
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will open a half width element for form fields
|
||||
* @param string $title
|
||||
* @return void
|
||||
*/
|
||||
private static function openHalfWidthElement(string $title = '')
|
||||
{
|
||||
Form::addOpenDiv([
|
||||
'class' => 'col-lg-6 col-13',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenDiv([
|
||||
'class' => 'box',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenDiv([
|
||||
'class' => 'box-header with-border',
|
||||
'value' => '<h4 class="box-title">'.$title.'</h4>'
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will add the inner half width distance frame element for form fields
|
||||
* @return void
|
||||
*/
|
||||
private static function openInnerHalfWithElement()
|
||||
{
|
||||
Form::addOpenDiv([
|
||||
'class' => 'form-group',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenDiv([
|
||||
'class' => 'box-body',
|
||||
'value' => ''
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will add a checkbox for the active user account state
|
||||
* @param string $visibleTitle
|
||||
* @param bool $active
|
||||
* @return void
|
||||
*/
|
||||
private static function addActiveCheckbox(string $visibleTitle = '', int $active = 0)
|
||||
{
|
||||
Form::addOpenDiv([
|
||||
'class' => 'form-group', 'value' => ''
|
||||
]);
|
||||
Form::addOpenDiv([
|
||||
'class' => 'checkbox checkbox-success',
|
||||
'value' => ''
|
||||
]);
|
||||
if($active)
|
||||
{
|
||||
Form::addInputTypeCheckbox([
|
||||
'id' => 'checkbox',
|
||||
'name' => 'user_account_active',
|
||||
'checked' => true
|
||||
]);
|
||||
}
|
||||
else
|
||||
{
|
||||
Form::addInputTypeCheckbox([
|
||||
'id' => 'checkbox',
|
||||
'name' => 'account_active'
|
||||
]);
|
||||
}
|
||||
Form::addTypeLabel([
|
||||
'for' => 'checkbox',
|
||||
'value' => $visibleTitle
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
Form::addCloseDiv();
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will close the inner half width distance frame element, and the half width element for form fields
|
||||
* @return void
|
||||
*/
|
||||
private static function closeHalfWidthElement()
|
||||
{
|
||||
Form::addCloseDiv();
|
||||
Form::addCloseDiv();
|
||||
}
|
||||
|
||||
}
|
||||
144
application/module/users/traits/users.php
Executable file
144
application/module/users/traits/users.php
Executable file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Users\Traits;
|
||||
/**
|
||||
* @desc this file is for the autoloader to function properly,
|
||||
* you might as well use it as the primary user trait for your
|
||||
* application
|
||||
* Created by PhpStorm.
|
||||
* User: kasdorf
|
||||
* Date: 28.08.18
|
||||
* Time: 11:24
|
||||
*/
|
||||
use Nibiru\Factory\Form;
|
||||
|
||||
trait Users
|
||||
{
|
||||
/**
|
||||
* @desc this will return the login form
|
||||
* @return string
|
||||
*/
|
||||
public function loginForm(): string
|
||||
{
|
||||
Form::create();
|
||||
//Start form field Username
|
||||
Form::addOpenDiv([
|
||||
'class' => 'form-group',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenDiv([
|
||||
'class' => 'input-group mb-3',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenAny([
|
||||
'any' => 'span',
|
||||
'class' => 'input-group-text bg-transparent',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenAny([
|
||||
'any' => 'i',
|
||||
'class' => 'ti-user',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addCloseAny([
|
||||
'any' => 'i'
|
||||
]);
|
||||
Form::addCloseAny([
|
||||
'any' => 'span'
|
||||
]);
|
||||
Form::addInputTypeText([
|
||||
'class' => 'form-control ps-15 bg-transparent',
|
||||
'name' => 'login',
|
||||
'placeholder' => 'Benutzername'
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
Form::addCloseDiv();
|
||||
//Start form field Password
|
||||
Form::addOpenDiv([
|
||||
'class' => 'form-group',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenDiv([
|
||||
'class' => 'input-group mb-3',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenAny([
|
||||
'any' => 'span',
|
||||
'class' => 'input-group-text bg-transparent',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenAny([
|
||||
'any' => 'i',
|
||||
'class' => 'ti-lock',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addCloseAny([
|
||||
'any' => 'i'
|
||||
]);
|
||||
Form::addCloseAny([
|
||||
'any' => 'span'
|
||||
]);
|
||||
Form::addInputTypePassword([
|
||||
'class' => 'form-control ps-15 bg-transparent',
|
||||
'name' => 'password',
|
||||
'placeholder' => 'Passwort'
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
Form::addCloseDiv();
|
||||
//End form field Password
|
||||
Form::addOpenDiv([
|
||||
'class' => 'row',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenDiv([
|
||||
'class' => 'col-6',
|
||||
'value' => ''
|
||||
]);
|
||||
|
||||
Form::addCloseDiv();
|
||||
|
||||
|
||||
Form::addOpenDiv([
|
||||
'class' => 'col-6',
|
||||
'value' => ''
|
||||
]);
|
||||
Form::addOpenDiv([
|
||||
'class' => 'fog-pwd text-end',
|
||||
'value' => ''
|
||||
]);
|
||||
|
||||
Form::addOpenAny([
|
||||
'any' => 'a',
|
||||
'href' => 'javascript:void(0)',
|
||||
'class' => 'hover-warning',
|
||||
'value' => '<i class="ion ion-locked"></i> Passwort vergessen?'
|
||||
]);
|
||||
Form::addCloseAny([
|
||||
'any' => 'a'
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
Form::addCloseDiv();
|
||||
|
||||
Form::addOpenDiv([
|
||||
'class' => 'col-12 text-center',
|
||||
'value' => ''
|
||||
]);
|
||||
|
||||
Form::addTypeButton([
|
||||
'type' => 'submit',
|
||||
'class' => 'btn btn-danger mt-10',
|
||||
'value' => 'Anmelden'
|
||||
]);
|
||||
Form::addCloseDiv();
|
||||
Form::addCloseDiv();
|
||||
|
||||
return Form::addForm([
|
||||
'name' => 'loginForm',
|
||||
'action' => '/users/login',
|
||||
'method' => 'post',
|
||||
'target' => '_self'
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
80
application/module/users/users.php
Executable file
80
application/module/users/users.php
Executable file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
namespace Nibiru\Module\Users;
|
||||
/**
|
||||
* @desc this file is for the autoloader to function properly,
|
||||
* you might as well use it as the primary user class for your
|
||||
* application, the interface an the trait are currently implemented
|
||||
* Created by PhpStorm.
|
||||
* User: kasdorf
|
||||
* Date: 28.08.18
|
||||
* Time: 11:22
|
||||
*/
|
||||
use Nibiru\Module\Users\Interfaces;
|
||||
use Nibiru\Module\Users\Traits;
|
||||
use Nibiru\Registry;
|
||||
use SplSubject;
|
||||
use SplObserver;
|
||||
use SplObjectStorage;
|
||||
|
||||
class Users implements Interfaces\Users, SplSubject
|
||||
{
|
||||
use Traits\Users;
|
||||
protected static object $usersRegistry;
|
||||
protected SplObjectStorage $observers;
|
||||
/**
|
||||
* Users constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->setUsersRegistry();
|
||||
$this->observers = new SplObjectStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will attach an observer to the observer storage
|
||||
* @param SplObserver $observer
|
||||
* @return void
|
||||
*/
|
||||
public function attach(SplObserver $observer): void
|
||||
{
|
||||
$this->observers->attach($observer);
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will detach an observer from the observer storage
|
||||
* @param SplObserver $observer
|
||||
* @return void
|
||||
*/
|
||||
public function detach(SplObserver $observer): void
|
||||
{
|
||||
$this->observers->detach($observer);
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will notify all observers
|
||||
* @return void
|
||||
*/
|
||||
public function notify(): void
|
||||
{
|
||||
foreach ($this->observers as $observer) {
|
||||
$observer->update($this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc will return the user registry object from the users.ini configuration
|
||||
* @return object
|
||||
*/
|
||||
protected static function getUsersRegistry(): object
|
||||
{
|
||||
return self::$usersRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc setter for the user Registry
|
||||
*/
|
||||
protected function setUsersRegistry(): void
|
||||
{
|
||||
self::$usersRegistry = Registry::getInstance()->loadModuleConfigByName(self::CONFIG_MODULE_NAME);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user