Добавлена авторизация (локальная и через Moodle), локальная регистрация
This commit is contained in:
parent
bdecc30299
commit
eeb4a39506
1335
package-lock.json
generated
1335
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@
|
|||||||
"@sveltejs/adapter-auto": "^6.1.1",
|
"@sveltejs/adapter-auto": "^6.1.1",
|
||||||
"@sveltejs/kit": "^2.43.2",
|
"@sveltejs/kit": "^2.43.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/eslint__js": "^8.42.3",
|
"@types/eslint__js": "^8.42.3",
|
||||||
"@types/prettier": "^2.7.3",
|
"@types/prettier": "^2.7.3",
|
||||||
"eslint": "^9.38.0",
|
"eslint": "^9.38.0",
|
||||||
@ -21,5 +22,10 @@
|
|||||||
"svelte": "^5.39.5",
|
"svelte": "^5.39.5",
|
||||||
"typescript-eslint": "^8.46.1",
|
"typescript-eslint": "^8.46.1",
|
||||||
"vite": "^7.1.7"
|
"vite": "^7.1.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/sveltekit": "^1.11.2",
|
||||||
|
"better-sqlite3": "^12.9.0",
|
||||||
|
"drizzle-orm": "^0.45.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
87
src/auth.js
Normal file
87
src/auth.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { SvelteKitAuth } from '@auth/sveltekit';
|
||||||
|
import Credentials from '@auth/sveltekit/providers/credentials';
|
||||||
|
import { db, users } from '$lib/server/db.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { verifyPassword, createMoodleUser } from '$lib/server/auth.js';
|
||||||
|
import { moodleLogin } from '$lib/server/moodle.js';
|
||||||
|
|
||||||
|
export const { handle, signIn, signOut } = SvelteKitAuth({
|
||||||
|
providers: [
|
||||||
|
//локальный аккаунт (email + пароль)
|
||||||
|
Credentials({
|
||||||
|
id: 'local',
|
||||||
|
name: 'Локальный аккаунт',
|
||||||
|
credentials: {
|
||||||
|
email: { label: 'Email', type: 'email' },
|
||||||
|
password: { label: 'Пароль', type: 'password' },
|
||||||
|
},
|
||||||
|
async authorize({ email, password }) {
|
||||||
|
if (!email || !password) return null;
|
||||||
|
const user = db.select().from(users).where(eq(users.email, email)).get();
|
||||||
|
if (!user || user.authType !== 'local' || !user.passwordHash) return null;
|
||||||
|
if (!verifyPassword(password, user.passwordHash)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.username,
|
||||||
|
email: user.email,
|
||||||
|
authType: 'local',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
//вход через Moodle
|
||||||
|
Credentials({
|
||||||
|
id: 'moodle',
|
||||||
|
name: 'Moodle',
|
||||||
|
credentials: {
|
||||||
|
username: { label: 'Логин Moodle', type: 'text' },
|
||||||
|
password: { label: 'Пароль', type: 'password' },
|
||||||
|
},
|
||||||
|
async authorize({ username, password }) {
|
||||||
|
if (!username || !password) return null;
|
||||||
|
try {
|
||||||
|
//проверяем логин/пароль через Moodle API (moodle.js)
|
||||||
|
const mUser = await moodleLogin(username, password);
|
||||||
|
//создаём или находим пользователя в нашей БД
|
||||||
|
const userId = createMoodleUser(mUser.moodleId, mUser.username, mUser.email);
|
||||||
|
const user = db.select().from(users).where(eq(users.id, userId)).get();
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.username,
|
||||||
|
email: user.email,
|
||||||
|
authType: 'moodle',
|
||||||
|
moodleId: user.moodleId,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null; //неверный логин/пароль
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
callbacks: {
|
||||||
|
//добавляем в JWT токен
|
||||||
|
jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
token.authType = user.authType;
|
||||||
|
token.moodleId = user.moodleId ?? null;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
//передаём в объект сессии
|
||||||
|
session({ session, token }) {
|
||||||
|
session.user.id = token.id;
|
||||||
|
session.user.authType = token.authType;
|
||||||
|
session.user.moodleId = token.moodleId ?? null;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
pages: {
|
||||||
|
signIn: '/login',
|
||||||
|
},
|
||||||
|
trustHost: true,
|
||||||
|
});
|
||||||
11
src/hooks.server.js
Normal file
11
src/hooks.server.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { handle as authHandle } from './auth.js';
|
||||||
|
import { sequence } from '@sveltejs/kit/hooks';
|
||||||
|
|
||||||
|
export const handle = sequence(
|
||||||
|
authHandle,
|
||||||
|
async ({ event, resolve }) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
event.locals.user = session?.user ?? null;
|
||||||
|
return resolve(event);
|
||||||
|
}
|
||||||
|
);
|
||||||
@ -10,6 +10,36 @@
|
|||||||
|
|
||||||
let selectedTool = 'bit'; // 'bit', 'byte', '2bytes', '4bytes', '6bytes', '8bytes'
|
let selectedTool = 'bit'; // 'bit', 'byte', '2bytes', '4bytes', '6bytes', '8bytes'
|
||||||
|
|
||||||
|
let dragging = false;
|
||||||
|
let posX = null, posY = null;
|
||||||
|
let dragOffsetX = 0, dragOffsetY = 0;
|
||||||
|
let popupEl;
|
||||||
|
|
||||||
|
$: if (show) { posX = null; posY = null; }
|
||||||
|
|
||||||
|
function startDrag(e) {
|
||||||
|
if (e.target.closest('button, input, select, label')) return;
|
||||||
|
dragging = true;
|
||||||
|
const rect = popupEl.getBoundingClientRect();
|
||||||
|
dragOffsetX = e.clientX - rect.left;
|
||||||
|
dragOffsetY = e.clientY - rect.top;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(e) {
|
||||||
|
if (!dragging) return;
|
||||||
|
posX = e.clientX - dragOffsetX;
|
||||||
|
posY = e.clientY - dragOffsetY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDrag() {
|
||||||
|
dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: popupStyle = posX !== null
|
||||||
|
? `left:${posX}px; top:${posY}px; transform:none;`
|
||||||
|
: `left:50%; top:50%; transform:translate(-50%,-50%);`;
|
||||||
|
|
||||||
function isToolValid(tool, startIndex) {
|
function isToolValid(tool, startIndex) {
|
||||||
for (let i = 0; i < tool.bytes; i++) {
|
for (let i = 0; i < tool.bytes; i++) {
|
||||||
const byteIndex = startIndex + i;
|
const byteIndex = startIndex + i;
|
||||||
@ -51,147 +81,181 @@
|
|||||||
$: selectedToolData = availableTools.find(t => t.id === selectedTool);
|
$: selectedToolData = availableTools.find(t => t.id === selectedTool);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:mousemove={onMove} on:mouseup={stopDrag} />
|
||||||
|
|
||||||
{#if show}
|
{#if show}
|
||||||
<div
|
<div
|
||||||
class="tool-selector-overlay"
|
bind:this={popupEl}
|
||||||
style="position: fixed; left: 0; top: 0; right: 0; bottom: 0; z-index: 1000;"
|
class="popup"
|
||||||
|
class:dragging
|
||||||
|
style={popupStyle}
|
||||||
|
role="dialog"
|
||||||
>
|
>
|
||||||
<div
|
<!--заголовок (тянем за него)-->
|
||||||
class="tool-selector-popup"
|
<div class="popup-header" on:mousedown={startDrag} role="presentation" tabindex="-1">
|
||||||
style="
|
<span class="drag-icon">⠿</span>
|
||||||
position: absolute;
|
<span class="title">Редактирование байта {startIndex}</span>
|
||||||
left: 50%;
|
<button class="close-x" on:click={() => show = false}>✕</button>
|
||||||
top: 50%;
|
</div>
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
max-width: 90vw;
|
|
||||||
max-height: 90vh;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div class="tool-header">
|
|
||||||
<h4>Редактирование байта {startIndex}</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tool-selection">
|
|
||||||
{#each availableTools as tool}
|
|
||||||
<button
|
|
||||||
class:selected={selectedTool === tool.id}
|
|
||||||
class="tool-button"
|
|
||||||
on:click={() => selectTool(tool.id)}
|
|
||||||
>
|
|
||||||
{tool.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tool-content">
|
<!--выбор инструмента-->
|
||||||
{#if selectedTool === 'bit'}
|
<div class="tools-row">
|
||||||
<BitEditor
|
{#each availableTools as tool}
|
||||||
buffer={new Uint8Array([buffer[startIndex]])}
|
<button
|
||||||
onBufferChange={(newByte) => {
|
class="tool-btn"
|
||||||
const newBuffer = new Uint8Array(buffer);
|
class:active={selectedTool === tool.id}
|
||||||
newBuffer[startIndex] = newByte[0];
|
on:click={() => selectedTool = tool.id}
|
||||||
onBufferChange(newBuffer);
|
>{tool.name}</button>
|
||||||
}}
|
{/each}
|
||||||
/>
|
</div>
|
||||||
{:else if selectedToolData}
|
|
||||||
<ByteEditor
|
|
||||||
buffer={buffer}
|
|
||||||
onBufferChange={onBufferChange}
|
|
||||||
startIndex={startIndex}
|
|
||||||
byteLength={selectedToolData.bytes}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tool-footer">
|
<div class="content">
|
||||||
<button on:click={() => show = false} class="close-button">
|
{#if selectedTool === 'bit'}
|
||||||
Закрыть
|
<BitEditor
|
||||||
</button>
|
buffer={new Uint8Array([buffer[startIndex]])}
|
||||||
</div>
|
onBufferChange={(newByte) => {
|
||||||
|
const newBuffer = new Uint8Array(buffer);
|
||||||
|
newBuffer[startIndex] = newByte[0];
|
||||||
|
onBufferChange(newBuffer);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else if selectedToolData}
|
||||||
|
<ByteEditor
|
||||||
|
buffer={buffer}
|
||||||
|
onBufferChange={onBufferChange}
|
||||||
|
startIndex={startIndex}
|
||||||
|
byteLength={selectedToolData.bytes}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<button class="close-btn" on:click={() => show = false}>Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.tool-selector-overlay {
|
.popup {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
position: fixed;
|
||||||
display: flex;
|
z-index: 1000;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-selector-popup {
|
|
||||||
background: white;
|
background: white;
|
||||||
border: 2px solid #2196F3;
|
border: 2px solid #2196F3;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
|
min-width: 380px;
|
||||||
overflow-y: auto;
|
max-width: 90vw;
|
||||||
min-width: 400px;
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-header {
|
.popup.dragging { cursor: grabbing; box-shadow: 0 12px 40px rgba(0,0,0,0.3); }
|
||||||
margin-bottom: 16px;
|
|
||||||
border-bottom: 1px solid #eee;
|
/*заголовок*/
|
||||||
padding-bottom: 12px;
|
.popup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
cursor: grab;
|
||||||
|
background: #f8f9ff;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-icon {
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1565c0;
|
||||||
|
font-size: 0.95em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-header h4 {
|
.close-x {
|
||||||
margin: 0 0 4px 0;
|
background: none;
|
||||||
color: #2196F3;
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-selection {
|
.close-x:hover {
|
||||||
|
color: #f44336;
|
||||||
|
background: #ffebee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*инструменты*/
|
||||||
|
.tools-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
margin-bottom: 20px;
|
padding: 12px 14px;
|
||||||
justify-content: center;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-button {
|
.tool-btn {
|
||||||
padding: 8px 16px;
|
padding: 5px 12px;
|
||||||
border: 2px solid #e0e0e0;
|
border: 1px solid #ddd;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9em;
|
font-size: 0.82em;
|
||||||
transition: all 0.2s;
|
transition: all 0.15s;
|
||||||
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-button:hover {
|
.tool-btn:hover {
|
||||||
background: #f0f7ff;
|
|
||||||
border-color: #90caf9;
|
border-color: #90caf9;
|
||||||
|
color: #1565c0;
|
||||||
|
background: #f0f7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-button.selected {
|
.tool-btn.active {
|
||||||
background: #2196F3;
|
background: #2196F3;
|
||||||
color: white;
|
color: white;
|
||||||
border-color: #1976D2;
|
border-color: #1976D2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-content {
|
.content {
|
||||||
max-height: 60vh;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin-bottom: 16px;
|
padding: 12px 14px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-footer {
|
.footer {
|
||||||
|
padding: 10px 14px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-button {
|
.close-btn {
|
||||||
padding: 8px 24px;
|
padding: 7px 22px;
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
border: 2px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #333;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: bold;
|
font-size: 0.9em;
|
||||||
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-button:hover {
|
.close-btn:hover {
|
||||||
background: #e0e0e0;
|
background: #e0e0e0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@ -139,7 +139,7 @@ export const lessons = [
|
|||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
slug: 'ipv4-ttl',
|
slug: 'ipv4-ttl',
|
||||||
title: 'TTL — время жизни пакета',
|
title: 'TTL - время жизни пакета',
|
||||||
category: 'IPv4',
|
category: 'IPv4',
|
||||||
difficulty: 'Средний',
|
difficulty: 'Средний',
|
||||||
component: 'HexEditor',
|
component: 'HexEditor',
|
||||||
|
|||||||
@ -103,12 +103,22 @@ export const theory = {
|
|||||||
|
|
||||||
<div style="${B}">
|
<div style="${B}">
|
||||||
<h3 style="${H3}">Структура кадра Ethernet II</h3>
|
<h3 style="${H3}">Структура кадра Ethernet II</h3>
|
||||||
<div style="${DARK}">
|
<table style="border-collapse:collapse;width:100%;margin:10px 0;font-size:0.9em;">
|
||||||
<div>+----------+----------+----------+------------------+------+</div>
|
<tr>
|
||||||
<div>| Dst MAC | Src MAC |EtherType | Data | FCS |</div>
|
<th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">Dst MAC</th>
|
||||||
<div>| 6 байт | 6 байт | 2 байта | 46-1500 байт | 4 б |</div>
|
<th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">Src MAC</th>
|
||||||
<div>+----------+----------+----------+------------------+------+</div>
|
<th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">EtherType</th>
|
||||||
</div>
|
<th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">Data</th>
|
||||||
|
<th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">FCS</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #ddd;text-align:center;">6 байт</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #ddd;text-align:center;background:#f5f9ff;">6 байт</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #ddd;text-align:center;">2 байта</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #ddd;text-align:center;background:#f5f9ff;">46–1500 байт</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #ddd;text-align:center;">4 байта</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
<p><strong>Dst MAC</strong> — получатель. <strong>Src MAC</strong> — отправитель.</p>
|
<p><strong>Dst MAC</strong> — получатель. <strong>Src MAC</strong> — отправитель.</p>
|
||||||
<p><strong>EtherType</strong> — тип протокола: <code>0x0800</code> — IPv4, <code>0x0806</code> — ARP, <code>0x86DD</code> — IPv6.</p>
|
<p><strong>EtherType</strong> — тип протокола: <code>0x0800</code> — IPv4, <code>0x0806</code> — ARP, <code>0x86DD</code> — IPv6.</p>
|
||||||
<p><strong>FCS</strong> (Frame Check Sequence) — контрольная сумма, рассчитывается автоматически.</p>
|
<p><strong>FCS</strong> (Frame Check Sequence) — контрольная сумма, рассчитывается автоматически.</p>
|
||||||
@ -160,11 +170,11 @@ export const theory = {
|
|||||||
|
|
||||||
'ipv4-ttl': `
|
'ipv4-ttl': `
|
||||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||||
<h2 style="${H2}">TTL — время жизни пакета</h2>
|
<h2 style="${H2}">TTL - время жизни пакета</h2>
|
||||||
|
|
||||||
<div style="${B}">
|
<div style="${B}">
|
||||||
<h3 style="${H3}">Зачем нужен TTL?</h3>
|
<h3 style="${H3}">Зачем нужен TTL?</h3>
|
||||||
<p><strong>TTL (Time To Live)</strong> — счётчик прыжков. Каждый маршрутизатор уменьшает TTL на 1. При TTL = 0 пакет отбрасывается и отправителю уходит ICMP «Time Exceeded». Защита от бесконечной петли маршрутизации.</p>
|
<p><strong>TTL (Time To Live)</strong> - счётчик прыжков. Каждый маршрутизатор уменьшает TTL на 1. При TTL = 0 пакет отбрасывается и отправителю уходит ICMP «Time Exceeded». Защита от бесконечной петли маршрутизации.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="${B}">
|
<div style="${B}">
|
||||||
@ -180,7 +190,7 @@ export const theory = {
|
|||||||
|
|
||||||
<div style="${B}">
|
<div style="${B}">
|
||||||
<h3 style="${H3}">traceroute и TTL</h3>
|
<h3 style="${H3}">traceroute и TTL</h3>
|
||||||
<p><code>traceroute</code> отправляет пакеты с TTL=1, 2, 3… Каждый маршрутизатор отвечает ICMP «Time Exceeded» — так строится полная цепочка узлов до цели.</p>
|
<p><code>traceroute</code> отправляет пакеты с TTL=1, 2, 3… Каждый маршрутизатор отвечает ICMP «Time Exceeded» - так строится полная цепочка узлов до цели.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
|||||||
51
src/lib/server/auth.js
Normal file
51
src/lib/server/auth.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { db, users } from './db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto';
|
||||||
|
|
||||||
|
//пароли
|
||||||
|
|
||||||
|
export function hashPassword(password) {
|
||||||
|
const salt = randomBytes(16).toString('hex');
|
||||||
|
const hash = scryptSync(password, salt, 64).toString('hex');
|
||||||
|
return `${salt}:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password, stored) {
|
||||||
|
const [salt, hash] = stored.split(':');
|
||||||
|
const hashBuffer = Buffer.from(hash, 'hex');
|
||||||
|
const inputHash = scryptSync(password, salt, 64);
|
||||||
|
return timingSafeEqual(hashBuffer, inputHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
//пользователи
|
||||||
|
|
||||||
|
export function createLocalUser(username, email, password) {
|
||||||
|
const existing = db.select().from(users).where(eq(users.email, email)).get();
|
||||||
|
if (existing) throw new Error('Пользователь с таким email уже существует');
|
||||||
|
|
||||||
|
const id = randomBytes(16).toString('hex');
|
||||||
|
db.insert(users).values({
|
||||||
|
id, username, email,
|
||||||
|
passwordHash: hashPassword(password),
|
||||||
|
moodleId: null,
|
||||||
|
authType: 'local',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMoodleUser(moodleId, username, email) {
|
||||||
|
const existing = db.select().from(users)
|
||||||
|
.where(eq(users.moodleId, moodleId)).get();
|
||||||
|
if (existing) return existing.id;
|
||||||
|
|
||||||
|
const id = randomBytes(16).toString('hex');
|
||||||
|
db.insert(users).values({
|
||||||
|
id, username, email,
|
||||||
|
passwordHash: null,
|
||||||
|
moodleId,
|
||||||
|
authType: 'moodle',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
50
src/lib/server/db.js
Normal file
50
src/lib/server/db.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
|
//схема таблиц
|
||||||
|
|
||||||
|
export const users = sqliteTable('users', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
username: text('username').notNull(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
passwordHash: text('password_hash'), //null, если вход через Moodle
|
||||||
|
moodleId: text('moodle_id'), //null, если своя регистрация
|
||||||
|
authType: text('auth_type').notNull(), //'local' или 'moodle'
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const progress = sqliteTable('progress', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
lessonId: text('lesson_id').notNull(),
|
||||||
|
tasksCompleted: integer('tasks_completed').notNull().default(0),
|
||||||
|
completed: integer('completed').notNull().default(0), // 0 или 1
|
||||||
|
updatedAt: integer('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
//подключение
|
||||||
|
const sqlite = new Database('local.db');
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT,
|
||||||
|
moodle_id TEXT,
|
||||||
|
auth_type TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS progress (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
lesson_id TEXT NOT NULL,
|
||||||
|
tasks_completed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
completed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const db = drizzle(sqlite);
|
||||||
124
src/lib/server/moodle.js
Normal file
124
src/lib/server/moodle.js
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { MOODLE_URL, MOODLE_TOKEN, MOODLE_ASSIGNMENT_ID } from '$env/static/private';
|
||||||
|
|
||||||
|
async function moodleRequest(wsfunction, params = {}) {
|
||||||
|
const url = new URL(`${MOODLE_URL}/webservice/rest/server.php`);
|
||||||
|
url.searchParams.set('wstoken', MOODLE_TOKEN);
|
||||||
|
url.searchParams.set('wsfunction', wsfunction);
|
||||||
|
url.searchParams.set('moodlewsrestformat', 'json');
|
||||||
|
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.exception) throw new Error(data.message ?? 'Moodle API error');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
//cобирает все Set-Cookie заголовки в одну строку
|
||||||
|
function extractCookies(response) {
|
||||||
|
const raw = response.headers.getSetCookie?.()
|
||||||
|
?? [response.headers.get('set-cookie') ?? ''];
|
||||||
|
return raw.map(c => c.split(';')[0]).filter(Boolean).join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
//объединяет старые и новые куки (новые перезаписывают старые)
|
||||||
|
function mergeCookies(existing, incoming) {
|
||||||
|
const map = {};
|
||||||
|
[...existing.split('; '), ...incoming.split('; ')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach(pair => {
|
||||||
|
const [k, v] = pair.split('=');
|
||||||
|
if (k) map[k.trim()] = v ?? '';
|
||||||
|
});
|
||||||
|
return Object.entries(map).map(([k, v]) => `${k}=${v}`).join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moodleLogin(username, password) {
|
||||||
|
//получаем logintoken и начальные cookies
|
||||||
|
const loginPageRes = await fetch(`${MOODLE_URL}/login/index.php`);
|
||||||
|
const loginPageHtml = await loginPageRes.text();
|
||||||
|
let cookies = extractCookies(loginPageRes);
|
||||||
|
|
||||||
|
const tokenMatch = loginPageHtml.match(/name="logintoken"\s+value="([^"]+)"/);
|
||||||
|
const logintoken = tokenMatch ? tokenMatch[1] : '';
|
||||||
|
|
||||||
|
//POST логин/пароль
|
||||||
|
const formRes = await fetch(`${MOODLE_URL}/login/index.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': cookies },
|
||||||
|
body: new URLSearchParams({ username, password, logintoken }),
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
cookies = mergeCookies(cookies, extractCookies(formRes));
|
||||||
|
let location = formRes.headers.get('location') ?? '';
|
||||||
|
|
||||||
|
//если testsession, то следуем редиректу с куками
|
||||||
|
if (location.includes('testsession')) {
|
||||||
|
const testRes = await fetch(location, {
|
||||||
|
headers: { 'Cookie': cookies },
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
cookies = mergeCookies(cookies, extractCookies(testRes));
|
||||||
|
location = testRes.headers.get('location') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
//проверяем финальный редирект
|
||||||
|
if (!location || (!location.includes('/my') && !location.includes('dashboard'))) {
|
||||||
|
throw new Error('Неверный логин или пароль');
|
||||||
|
}
|
||||||
|
|
||||||
|
//получаем данные пользователя через админский токен
|
||||||
|
const users = await moodleRequest('core_user_get_users_by_field', {
|
||||||
|
field: 'username', 'values[0]': username,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!users || users.length === 0) throw new Error('Пользователь не найден в Moodle');
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
return {
|
||||||
|
moodleId: String(user.id),
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
fullname: user.fullname,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moodlePost(wsfunction, params = {}) {
|
||||||
|
const allParams = {
|
||||||
|
wstoken: MOODLE_TOKEN,
|
||||||
|
wsfunction,
|
||||||
|
moodlewsrestformat: 'json',
|
||||||
|
...params,
|
||||||
|
};
|
||||||
|
const body = Object.entries(allParams)
|
||||||
|
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
const res = await fetch(`${MOODLE_URL}/webservice/rest/server.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.exception) throw new Error(data.message ?? 'Moodle API error');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
//синхронизация с Moodle Gradebook
|
||||||
|
|
||||||
|
//синхронизирует завершение урока с Moodle Gradebook
|
||||||
|
//оценка 100 - урок завершён
|
||||||
|
export async function syncMoodleGrade(moodleUserId, completedCount, totalCount) {
|
||||||
|
const grade = Math.round((completedCount / totalCount) * 100);
|
||||||
|
|
||||||
|
await moodlePost('mod_assign_save_grade', {
|
||||||
|
'assignmentid': Number(MOODLE_ASSIGNMENT_ID),
|
||||||
|
'userid': Number(moodleUserId),
|
||||||
|
'grade': grade,
|
||||||
|
'attemptnumber': -1,
|
||||||
|
'addattempt': 0,
|
||||||
|
'workflowstate': '',
|
||||||
|
'applytoall': 1,
|
||||||
|
'plugindata[assignfeedbackcomments_editor][text]': `Пройдено: ${completedCount} из ${totalCount}`,
|
||||||
|
'plugindata[assignfeedbackcomments_editor][format]': 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,3 +1,8 @@
|
|||||||
|
//прогресс хранится в БД если пользователь авторизован,
|
||||||
|
//иначе в localStorage (для гостей)
|
||||||
|
|
||||||
|
const TASKS_REQUIRED = 3;
|
||||||
|
|
||||||
export const progressStorage = {
|
export const progressStorage = {
|
||||||
_getAll() {
|
_getAll() {
|
||||||
if (typeof window === 'undefined') return {};
|
if (typeof window === 'undefined') return {};
|
||||||
@ -17,7 +22,7 @@ export const progressStorage = {
|
|||||||
const all = this._getAll();
|
const all = this._getAll();
|
||||||
const raw = all[String(lessonId)];
|
const raw = all[String(lessonId)];
|
||||||
if (raw === undefined || raw === null) return { completed: false, tasksCompleted: 0 };
|
if (raw === undefined || raw === null) return { completed: false, tasksCompleted: 0 };
|
||||||
if (typeof raw === 'boolean') return { completed: raw, tasksCompleted: raw ? 3 : 0 };
|
if (typeof raw === 'boolean') return { completed: raw, tasksCompleted: raw ? TASKS_REQUIRED : 0 };
|
||||||
return { completed: !!raw.completed, tasksCompleted: raw.tasksCompleted ?? 0 };
|
return { completed: !!raw.completed, tasksCompleted: raw.tasksCompleted ?? 0 };
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -30,11 +35,12 @@ export const progressStorage = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
//сохраняет новое значение счётчика; автоматически выставляет completed
|
//сохраняет новое значение счётчика; автоматически выставляет completed
|
||||||
saveTasksCompleted(lessonId, tasksCompleted, tasksRequired = 3) {
|
saveTasksCompleted(lessonId, tasksCompleted, tasksRequired = TASKS_REQUIRED) {
|
||||||
const all = this._getAll();
|
const all = this._getAll();
|
||||||
const completed = tasksCompleted >= tasksRequired;
|
const completed = tasksCompleted >= tasksRequired;
|
||||||
all[String(lessonId)] = { completed, tasksCompleted };
|
all[String(lessonId)] = { completed, tasksCompleted };
|
||||||
this._save(all);
|
this._save(all);
|
||||||
|
this._syncToDB(lessonId, tasksCompleted, completed);
|
||||||
return completed;
|
return completed;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -48,4 +54,62 @@ export const progressStorage = {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
//вызывается при каждом сохранении - если пользователь авторизован, дублирует прогресс в БД
|
||||||
|
_syncToDB(lessonId, tasksCompleted, completed) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
fetch('/api/progress', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ lessonId, tasksCompleted, completed }),
|
||||||
|
}).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
//загружает прогресс из БД и синхронизирует с localStorage
|
||||||
|
async loadFromDB() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/progress');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
//объединяем: берём максимум из localStorage и БД
|
||||||
|
const local = this._getAll();
|
||||||
|
for (const [lessonId, dbVal] of Object.entries(data)) {
|
||||||
|
const localVal = local[String(lessonId)];
|
||||||
|
const localTC = typeof localVal === 'boolean'
|
||||||
|
? (localVal ? TASKS_REQUIRED : 0)
|
||||||
|
: (localVal?.tasksCompleted ?? 0);
|
||||||
|
const dbTC = dbVal.tasksCompleted ?? 0;
|
||||||
|
const bestTC = Math.max(localTC, dbTC);
|
||||||
|
|
||||||
|
local[String(lessonId)] = {
|
||||||
|
completed: bestTC >= TASKS_REQUIRED,
|
||||||
|
tasksCompleted: bestTC,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this._save(local);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
//переносит прогресс из localStorage в БД при входе/регистрации
|
||||||
|
async transferToDB() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const local = this._getAll();
|
||||||
|
if (Object.keys(local).length === 0) return;
|
||||||
|
try {
|
||||||
|
await fetch('/api/progress', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(local),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
|
||||||
|
//очищает localStorage (вызывается при выходе)
|
||||||
|
clear() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('networking-progress');
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
3
src/routes/+page.server.js
Normal file
3
src/routes/+page.server.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export async function load({ locals }) {
|
||||||
|
return { user: locals.user };
|
||||||
|
}
|
||||||
@ -3,15 +3,50 @@
|
|||||||
import { progressStorage } from '$lib/utils/storage';
|
import { progressStorage } from '$lib/utils/storage';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
export let data; //получаем user
|
||||||
|
$: user = data?.user ?? null;
|
||||||
|
|
||||||
let progress = {};
|
let progress = {};
|
||||||
|
let progressData = {}; //детальный прогресс для плашек { tasksCompleted, completed }
|
||||||
|
let showProfileMenu = false;
|
||||||
|
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
let activeCategory = 'Все';
|
let activeCategory = 'Все';
|
||||||
let activeDifficulty = 'Все';
|
let activeDifficulty = 'Все';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
|
if (user) {
|
||||||
|
await progressStorage.transferToDB(); //localStorage -> БД при первом входе
|
||||||
|
await progressStorage.loadFromDB(); //БД -> localStorage
|
||||||
|
}
|
||||||
|
progressData = progressStorage._getAll();
|
||||||
progress = progressStorage.getProgress();
|
progress = progressStorage.getProgress();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//профиль
|
||||||
|
async function logout() {
|
||||||
|
progressStorage.clear();
|
||||||
|
await fetch('/auth/signout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
});
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccount() {
|
||||||
|
if (!confirm('Удалить аккаунт? Это действие необратимо.')) return;
|
||||||
|
const res = await fetch('/api/auth/delete', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
progressStorage.clear();
|
||||||
|
//выходим из Auth.js сессии
|
||||||
|
await fetch('/auth/signout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
});
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//уникальные категории и сложности из уроков
|
//уникальные категории и сложности из уроков
|
||||||
$: categories = ['Все', ...new Set(lessons.map(l => l.category))];
|
$: categories = ['Все', ...new Set(lessons.map(l => l.category))];
|
||||||
$: difficulties = ['Все', ...new Set(lessons.map(l => l.difficulty))];
|
$: difficulties = ['Все', ...new Set(lessons.map(l => l.difficulty))];
|
||||||
@ -28,15 +63,30 @@
|
|||||||
return matchSearch && matchCat && matchDiff;
|
return matchSearch && matchCat && matchDiff;
|
||||||
});
|
});
|
||||||
|
|
||||||
//счётчик завершённых уроков
|
|
||||||
$: completedCount = lessons.filter(l => progress[l.id]).length;
|
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
activeCategory = 'Все';
|
activeCategory = 'Все';
|
||||||
activeDifficulty = 'Все';
|
activeDifficulty = 'Все';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: lessonStatuses = Object.fromEntries(
|
||||||
|
lessons.map(l => {
|
||||||
|
const p = progressData[String(l.id)];
|
||||||
|
if (!p) return [l.id, 'none'];
|
||||||
|
if (typeof p === 'boolean') return [l.id, p ? 'completed' : 'none'];
|
||||||
|
if (p.completed) return [l.id, 'completed'];
|
||||||
|
if ((p.tasksCompleted ?? 0) > 0) return [l.id, 'in-progress'];
|
||||||
|
return [l.id, 'none'];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
//счётчик завершённых уроков
|
||||||
|
$: completedCount = Object.values(lessonStatuses).filter(s => s === 'completed').length;
|
||||||
|
|
||||||
|
function getLessonStatus(lessonId) {
|
||||||
|
return lessonStatuses[lessonId] ?? 'none';
|
||||||
|
}
|
||||||
|
|
||||||
//цвет тэга сложности
|
//цвет тэга сложности
|
||||||
function difficultyColor(d) {
|
function difficultyColor(d) {
|
||||||
return { 'Начинающий': '#4caf50', 'Средний': '#ff9800', 'Продвинутый': '#f44336' }[d] ?? '#2196f3';
|
return { 'Начинающий': '#4caf50', 'Средний': '#ff9800', 'Продвинутый': '#f44336' }[d] ?? '#2196f3';
|
||||||
@ -45,6 +95,39 @@
|
|||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
||||||
|
<div class="user-bar">
|
||||||
|
{#if user}
|
||||||
|
<div class="profile-wrap">
|
||||||
|
<button class="profile-btn"
|
||||||
|
on:click={() => showProfileMenu = !showProfileMenu}>
|
||||||
|
👤 {user.name}
|
||||||
|
<span class="auth-badge" class:moodle={user.authType === 'moodle'}>
|
||||||
|
{user.authType === 'moodle' ? 'Moodle' : 'Локальный'}
|
||||||
|
</span>
|
||||||
|
<span class="arrow">{showProfileMenu ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showProfileMenu}
|
||||||
|
<div class="profile-menu">
|
||||||
|
<button class="menu-item" on:click={logout}>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
{#if user.authType !== 'moodle'}
|
||||||
|
<button class="menu-item danger" on:click={deleteAccount}>
|
||||||
|
Удалить аккаунт
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="auth-links">
|
||||||
|
<a href="/login" class="auth-link">Войти</a>
|
||||||
|
<a href="/register" class="auth-link primary">Регистрация</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!--шапка-->
|
<!--шапка-->
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>Обучение сетевым протоколам</h1>
|
<h1>Обучение сетевым протоколам</h1>
|
||||||
@ -122,11 +205,16 @@
|
|||||||
<!--список уроков-->
|
<!--список уроков-->
|
||||||
<div class="lessons-list">
|
<div class="lessons-list">
|
||||||
{#each filtered as lesson (lesson.id)}
|
{#each filtered as lesson (lesson.id)}
|
||||||
<a href="/lessons/{lesson.slug}" class="lesson-card" class:done={progress[lesson.id]}>
|
{@const status = lessonStatuses[lesson.id] ?? 'none'}
|
||||||
|
<a href="/lessons/{lesson.slug}" class="lesson-card"
|
||||||
|
class:done={status === 'completed'}
|
||||||
|
class:in-progress={status === 'in-progress'}>
|
||||||
<div class="lesson-header">
|
<div class="lesson-header">
|
||||||
<h3>{lesson.title}</h3>
|
<h3>{lesson.title}</h3>
|
||||||
{#if progress[lesson.id]}
|
{#if status === 'completed'}
|
||||||
<span class="completed-badge">✅ Пройден</span>
|
<span class="status-badge done">✅ Пройден</span>
|
||||||
|
{:else if status === 'in-progress'}
|
||||||
|
<span class="status-badge progress">⏳ В процессе</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="lesson-footer">
|
<div class="lesson-footer">
|
||||||
@ -264,7 +352,9 @@
|
|||||||
color: #555;
|
color: #555;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag:hover { border-color: #2196F3; color: #2196F3; }
|
.tag:hover { border-color: #2196F3; color: #2196F3; }
|
||||||
|
|
||||||
.tag.active {
|
.tag.active {
|
||||||
background: #2196F3;
|
background: #2196F3;
|
||||||
color: white;
|
color: white;
|
||||||
@ -282,6 +372,7 @@
|
|||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reset-btn:hover { background: #f44336; color: white; }
|
.reset-btn:hover { background: #f44336; color: white; }
|
||||||
|
|
||||||
/* счётчик*/
|
/* счётчик*/
|
||||||
@ -309,15 +400,21 @@
|
|||||||
background: white;
|
background: white;
|
||||||
transition: all 0.18s;
|
transition: all 0.18s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lesson-card:hover {
|
.lesson-card:hover {
|
||||||
border-color: #2196F3;
|
border-color: #2196F3;
|
||||||
box-shadow: 0 3px 10px rgba(33,150,243,0.15);
|
box-shadow: 0 3px 10px rgba(33,150,243,0.15);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lesson-card.done {
|
.lesson-card.done {
|
||||||
border-left: 4px solid #4caf50;
|
border-left: 4px solid #4caf50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lesson-card.in-progress {
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
.lesson-header {
|
.lesson-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -333,9 +430,7 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.completed-badge {
|
.status-badge {
|
||||||
background: #e8f5e9;
|
|
||||||
color: #2e7d32;
|
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.78em;
|
font-size: 0.78em;
|
||||||
@ -344,6 +439,17 @@
|
|||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-badge.done {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.progress {
|
||||||
|
background: #fff8e1;
|
||||||
|
color: #f57f17;
|
||||||
|
border: 1px solid #ffe082;
|
||||||
|
}
|
||||||
|
|
||||||
.lesson-footer {
|
.lesson-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@ -385,4 +491,119 @@
|
|||||||
.filter-group { flex-direction: column; align-items: flex-start; }
|
.filter-group { flex-direction: column; align-items: flex-start; }
|
||||||
.filter-label { min-width: unset; }
|
.filter-label { min-width: unset; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-btn:hover {
|
||||||
|
background: #eeeeee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-badge {
|
||||||
|
font-size: 0.7em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-badge.moodle {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #e65100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 0.7em;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,.12);
|
||||||
|
min-width: 180px;
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 16px;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item.danger {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item.danger:hover {
|
||||||
|
background: #ffebee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #555;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link:hover {
|
||||||
|
border-color: #2196F3;
|
||||||
|
color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link.primary {
|
||||||
|
background: #2196F3;
|
||||||
|
color: white;
|
||||||
|
border-color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link.primary:hover {
|
||||||
|
background: #1565c0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
13
src/routes/api/auth/delete/+server.js
Normal file
13
src/routes/api/auth/delete/+server.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db, users, progress } from '$lib/server/db.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function POST({ locals }) {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) return json({ error: 'Не авторизован' }, { status: 401 });
|
||||||
|
|
||||||
|
db.delete(progress).where(eq(progress.userId, user.id)).run();
|
||||||
|
db.delete(users).where(eq(users.id, user.id)).run();
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
28
src/routes/api/auth/register/+server.js
Normal file
28
src/routes/api/auth/register/+server.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { createLocalUser, createSession } from '$lib/server/auth';
|
||||||
|
|
||||||
|
export async function POST({ request, cookies }) {
|
||||||
|
const { username, email, password } = await request.json();
|
||||||
|
|
||||||
|
if (!username || !email || !password) {
|
||||||
|
return json({ error: 'Заполните все поля' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return json({ error: 'Пароль должен быть не менее 8 символов' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = createLocalUser(username, email, password);
|
||||||
|
const session = createSession(userId);
|
||||||
|
cookies.set('session', session.id, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
});
|
||||||
|
return json({ success: true, transferProgress: true });
|
||||||
|
} catch (e) {
|
||||||
|
return json({ error: e.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,5 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { OPENROUTER_API_KEY } from '$env/static/private';
|
import { AI_API_KEY, AI_API_URL, AI_MODEL } from '$env/static/private';
|
||||||
|
|
||||||
const MODEL = 'openrouter/free'; // авто-роутер: всегда выбирает доступную бесплатную модель
|
|
||||||
|
|
||||||
export async function POST({ request }) {
|
export async function POST({ request }) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@ -14,24 +12,24 @@ export async function POST({ request }) {
|
|||||||
const prompt = buildPrompt(lessonType, taskDescription, currentState);
|
const prompt = buildPrompt(lessonType, taskDescription, currentState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
const response = await fetch(AI_API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
'Authorization': `Bearer ${AI_API_KEY}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'HTTP-Referer': 'https://localhost',
|
'HTTP-Referer': 'https://localhost',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: MODEL,
|
model: AI_MODEL,
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
max_tokens: 300,
|
max_tokens: 300,
|
||||||
temperature: 0.4,
|
temperature: 0.3,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const err = await response.text();
|
const err = await response.text();
|
||||||
console.error('OpenRouter error:', err);
|
console.error('AI API error:', err);
|
||||||
return json({ hint: null, error: 'Сервис подсказок недоступен' }, { status: 502 });
|
return json({ hint: null, error: 'Сервис подсказок недоступен' }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,14 +54,15 @@ export async function POST({ request }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPrompt(lessonType, taskDescription, currentState) {
|
function buildPrompt(lessonType, taskDescription, currentState) {
|
||||||
return `Ты — помощник в образовательном веб-приложении для изучения сетевых протоколов.
|
return `Ты помощник в образовательном веб-приложении для изучения сетевых протоколов.
|
||||||
Отвечай ТОЛЬКО на русском языке. Ответ должен быть коротким — максимум 2-3 предложения.
|
Отвечай ТОЛЬКО на русском языке. Ответ должен быть коротким — максимум 2-3 предложения.
|
||||||
Не используй markdown, не пиши заголовков. Пиши просто и понятно для начинающего.
|
Не используй markdown, не пиши заголовков. Никаких рассуждений, размышлений, "подумаем", "итак", "значит".
|
||||||
|
Сразу готовая подсказка без вступлений. Пиши просто и понятно для начинающего.
|
||||||
|
|
||||||
Задание: "${taskDescription}"
|
Задание: "${taskDescription}"
|
||||||
Тип задания: ${lessonType}
|
Тип задания: ${lessonType}
|
||||||
Состояние пользователя: ${JSON.stringify(currentState)}
|
Состояние пользователя: ${JSON.stringify(currentState)}
|
||||||
|
|
||||||
Дай подсказку — как двигаться к решению, НЕ называя конечный ответ прямо.
|
Дай короткую подсказу - как двигаться к решению, НЕ называя конечный ответ прямо.
|
||||||
Можно намекнуть на нужный байт, бит или концепцию.`;
|
Можно намекнуть на нужный байт, бит или концепцию.`;
|
||||||
}
|
}
|
||||||
130
src/routes/api/progress/+server.js
Normal file
130
src/routes/api/progress/+server.js
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db, progress as progressTable } from '$lib/server/db.js';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { syncMoodleGrade } from '$lib/server/moodle.js';
|
||||||
|
import { lessons } from '$lib/data/lessons.js';
|
||||||
|
|
||||||
|
//GET - загрузить прогресс пользователя
|
||||||
|
export async function GET({ locals }) {
|
||||||
|
if (!locals.user) return json({});
|
||||||
|
|
||||||
|
const rows = db.select().from(progressTable)
|
||||||
|
.where(eq(progressTable.userId, locals.user.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
result[row.lessonId] = {
|
||||||
|
completed: row.completed === 1,
|
||||||
|
tasksCompleted: row.tasksCompleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
//POST - сохранить прогресс урока
|
||||||
|
export async function POST({ request, locals }) {
|
||||||
|
if (!locals.user) return json({ error: 'Не авторизован' }, { status: 401 });
|
||||||
|
|
||||||
|
const { lessonId, tasksCompleted, completed } = await request.json();
|
||||||
|
|
||||||
|
const existing = db.select().from(progressTable)
|
||||||
|
.where(and(
|
||||||
|
eq(progressTable.userId, locals.user.id),
|
||||||
|
eq(progressTable.lessonId, String(lessonId)),
|
||||||
|
)).get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.update(progressTable)
|
||||||
|
.set({
|
||||||
|
tasksCompleted,
|
||||||
|
completed: completed ? 1 : 0,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(progressTable.userId, locals.user.id),
|
||||||
|
eq(progressTable.lessonId, String(lessonId)),
|
||||||
|
)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(progressTable).values({
|
||||||
|
userId: locals.user.id,
|
||||||
|
lessonId: String(lessonId),
|
||||||
|
tasksCompleted,
|
||||||
|
completed: completed ? 1 : 0,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
//если Moodle-пользователь и урок завершён, то синхронизируем с Gradebook
|
||||||
|
if (completed && locals.user.authType === 'moodle' && locals.user.moodleId) {
|
||||||
|
const completedCount = db.select().from(progressTable)
|
||||||
|
.where(and(
|
||||||
|
eq(progressTable.userId, locals.user.id),
|
||||||
|
eq(progressTable.completed, 1),
|
||||||
|
))
|
||||||
|
.all().length;
|
||||||
|
|
||||||
|
syncMoodleGrade(locals.user.moodleId, completedCount, lessons.length)
|
||||||
|
.catch(e => console.error('Moodle grade sync error:', e.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
//PUT - перенести прогресс из localStorage в БД при входе
|
||||||
|
export async function PUT({ request, locals }) {
|
||||||
|
if (!locals.user) return json({ error: 'Не авторизован' }, { status: 401 });
|
||||||
|
|
||||||
|
const localProgress = await request.json(); //{ lessonId: { completed, tasksCompleted } }
|
||||||
|
|
||||||
|
for (const [lessonId, data] of Object.entries(localProgress)) {
|
||||||
|
if (!data || typeof data.tasksCompleted !== 'number') continue;
|
||||||
|
|
||||||
|
const existing = db.select().from(progressTable)
|
||||||
|
.where(and(
|
||||||
|
eq(progressTable.userId, locals.user.id),
|
||||||
|
eq(progressTable.lessonId, String(lessonId)),
|
||||||
|
)).get();
|
||||||
|
|
||||||
|
//берём максимум из localStorage и БД
|
||||||
|
const bestCompleted = existing
|
||||||
|
? Math.max(existing.tasksCompleted, data.tasksCompleted)
|
||||||
|
: data.tasksCompleted;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.update(progressTable)
|
||||||
|
.set({
|
||||||
|
tasksCompleted: bestCompleted,
|
||||||
|
completed: bestCompleted >= 3 ? 1 : 0,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(progressTable.userId, locals.user.id),
|
||||||
|
eq(progressTable.lessonId, String(lessonId)),
|
||||||
|
)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(progressTable).values({
|
||||||
|
userId: locals.user.id,
|
||||||
|
lessonId: String(lessonId),
|
||||||
|
tasksCompleted: bestCompleted,
|
||||||
|
completed: bestCompleted >= 3 ? 1 : 0,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
//cинхронизируем завершённые уроки в Moodle Gradebook
|
||||||
|
if (locals.user.authType === 'moodle' && locals.user.moodleId) {
|
||||||
|
const completedCount = db.select().from(progressTable)
|
||||||
|
.where(and(
|
||||||
|
eq(progressTable.userId, locals.user.id),
|
||||||
|
eq(progressTable.completed, 1),
|
||||||
|
))
|
||||||
|
.all().length;
|
||||||
|
|
||||||
|
syncMoodleGrade(locals.user.moodleId, completedCount, lessons.length)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
@ -53,7 +53,7 @@
|
|||||||
readOnlyRanges = lesson.readOnlyRanges ?? [];
|
readOnlyRanges = lesson.readOnlyRanges ?? [];
|
||||||
|
|
||||||
tasksCompleted = progressStorage.getTasksCompleted(lesson.id.toString());
|
tasksCompleted = progressStorage.getTasksCompleted(lesson.id.toString());
|
||||||
currentTaskNum = Math.min(tasksCompleted + 1, TASKS_REQUIRED);
|
currentTaskNum = tasksCompleted + 1;
|
||||||
taskJustSolved = false;
|
taskJustSolved = false;
|
||||||
notification = { show: false, message: '', type: 'success' };
|
notification = { show: false, message: '', type: 'success' };
|
||||||
|
|
||||||
@ -61,7 +61,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadNextTask(isInitial = false) {
|
function loadNextTask(isInitial = false) {
|
||||||
if (!isInitial) currentTaskNum = Math.min(currentTaskNum + 1, TASKS_REQUIRED + 1);
|
if (!isInitial) currentTaskNum = tasksCompleted + 1;
|
||||||
taskJustSolved = false;
|
taskJustSolved = false;
|
||||||
aiMessage = null;
|
aiMessage = null;
|
||||||
aiError = null;
|
aiError = null;
|
||||||
@ -99,7 +99,7 @@
|
|||||||
? processBuffer(newBuffer, lesson.id)
|
? processBuffer(newBuffer, lesson.id)
|
||||||
: newBuffer;
|
: newBuffer;
|
||||||
|
|
||||||
if (aiMessage) { aiMessage = null; aiError = null; }
|
//if (aiMessage) { aiMessage = null; aiError = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkSolution() {
|
function checkSolution() {
|
||||||
|
|||||||
7
src/routes/login/+page.server.js
Normal file
7
src/routes/login/+page.server.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export async function load({ url }) {
|
||||||
|
const errorCode = url.searchParams.get('error');
|
||||||
|
const error = errorCode === 'CredentialsSignin'
|
||||||
|
? 'Неверный логин или пароль'
|
||||||
|
: null;
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
292
src/routes/login/+page.svelte
Normal file
292
src/routes/login/+page.svelte
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
<script>
|
||||||
|
import { progressStorage } from '$lib/utils/storage';
|
||||||
|
|
||||||
|
export let data; // { error } из load()
|
||||||
|
|
||||||
|
let activeTab = 'local';
|
||||||
|
let email = '';
|
||||||
|
let password = '';
|
||||||
|
let moodleUsername = '';
|
||||||
|
let moodlePassword = '';
|
||||||
|
let error = data?.error ?? '';
|
||||||
|
let loading = false;
|
||||||
|
let showPass = false;
|
||||||
|
let showMPass = false;
|
||||||
|
|
||||||
|
async function loginLocal() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/callback/local', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.url.includes('/login')) {
|
||||||
|
error = 'Неверный логин или пароль';
|
||||||
|
} else {
|
||||||
|
await progressStorage.transferToDB();
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error = 'Ошибка сети';
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginMoodle() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/callback/moodle', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ username: moodleUsername, password: moodlePassword }),
|
||||||
|
});
|
||||||
|
if (res.url.includes('/login')) {
|
||||||
|
error = 'Неверный логин или пароль Moodle';
|
||||||
|
} else {
|
||||||
|
await progressStorage.transferToDB();
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error = 'Ошибка сети';
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="card">
|
||||||
|
<a href="/" class="close-btn" title="На главную">✕</a>
|
||||||
|
<h1>Вход</h1>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab" class:active={activeTab === 'local'}
|
||||||
|
on:click={() => { activeTab = 'local'; error = ''; }}>
|
||||||
|
Свой аккаунт
|
||||||
|
</button>
|
||||||
|
<button class="tab" class:active={activeTab === 'moodle'}
|
||||||
|
on:click={() => { activeTab = 'moodle'; error = ''; }}>
|
||||||
|
Войти через Moodle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeTab === 'local'}
|
||||||
|
<div class="form">
|
||||||
|
<input type="email" bind:value={email} placeholder="Email" />
|
||||||
|
<div class="pass-wrap">
|
||||||
|
<input type={showPass ? 'text' : 'password'}
|
||||||
|
bind:value={password} placeholder="Пароль"
|
||||||
|
autocomplete="current-password" />
|
||||||
|
<button type="button" class="eye" on:click={() => showPass = !showPass}>
|
||||||
|
{showPass ? '🙈' : '👁️'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" on:click={loginLocal} disabled={loading}>
|
||||||
|
{loading ? 'Вход...' : 'Войти'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="link">Нет аккаунта? <a href="/register">Зарегистрироваться</a></p>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="form">
|
||||||
|
<p class="hint">Введите логин и пароль от аккаунта Moodle</p>
|
||||||
|
<input type="text" bind:value={moodleUsername} placeholder="Логин Moodle" />
|
||||||
|
<div class="pass-wrap">
|
||||||
|
<input type={showMPass ? 'text' : 'password'}
|
||||||
|
bind:value={moodlePassword} placeholder="Пароль Moodle"
|
||||||
|
autocomplete="current-password" />
|
||||||
|
<button type="button" class="eye" on:click={() => showMPass = !showMPass}>
|
||||||
|
{showMPass ? '🙈' : '👁️'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn-moodle" on:click={loginMoodle} disabled={loading}>
|
||||||
|
{loading ? 'Проверка...' : '🎓 Войти через Moodle'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="link">
|
||||||
|
Нет аккаунта Moodle?
|
||||||
|
<a href="https://moodle-production-c39f.up.railway.app/login/signup.php"
|
||||||
|
target="_blank">Зарегистрироваться в Moodle</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
min-height:100vh;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
background:#f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position:relative;
|
||||||
|
background:white;
|
||||||
|
border-radius:12px;
|
||||||
|
padding:40px;
|
||||||
|
width:100%;
|
||||||
|
max-width:420px;
|
||||||
|
box-shadow:0 4px 20px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
position:absolute;
|
||||||
|
top:14px;
|
||||||
|
right:14px;
|
||||||
|
font-size:1.1em;
|
||||||
|
color:#aaa;
|
||||||
|
text-decoration:none;
|
||||||
|
padding:4px 8px;
|
||||||
|
border-radius:50%;
|
||||||
|
transition:all .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color:#555;
|
||||||
|
background:#f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin:0 0 24px;
|
||||||
|
color:#1565c0;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display:flex;
|
||||||
|
border-bottom:2px solid #e0e0e0;
|
||||||
|
margin-bottom:24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex:1;
|
||||||
|
padding:10px;
|
||||||
|
border:none;
|
||||||
|
background:none;
|
||||||
|
cursor:pointer;
|
||||||
|
color:#888;
|
||||||
|
font-size:.95em;
|
||||||
|
border-bottom:2px solid transparent;
|
||||||
|
margin-bottom:-2px;
|
||||||
|
transition:all .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color:#1565c0;
|
||||||
|
border-bottom-color:#1565c0;
|
||||||
|
font-weight:bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
gap:12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width:100%;
|
||||||
|
padding:11px 14px;
|
||||||
|
border:1px solid #ddd;
|
||||||
|
border-radius:8px;
|
||||||
|
font-size:1em;
|
||||||
|
outline:none;
|
||||||
|
box-sizing:border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color:#2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-wrap {
|
||||||
|
position:relative;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-wrap input {
|
||||||
|
padding-right:44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye {
|
||||||
|
position:absolute;
|
||||||
|
right:10px;
|
||||||
|
background:none;
|
||||||
|
border:none;
|
||||||
|
cursor:pointer;
|
||||||
|
font-size:1.1em;
|
||||||
|
padding:4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background:#2196F3;
|
||||||
|
color:white;
|
||||||
|
border:none;
|
||||||
|
padding:12px;
|
||||||
|
border-radius:8px;
|
||||||
|
cursor:pointer;
|
||||||
|
font-size:1em;
|
||||||
|
font-weight:500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background:#1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-moodle {
|
||||||
|
background:#f98012;
|
||||||
|
color:white;
|
||||||
|
border:none;
|
||||||
|
padding:12px;
|
||||||
|
border-radius:8px;
|
||||||
|
cursor:pointer;
|
||||||
|
font-size:1em;
|
||||||
|
font-weight:500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-moodle:hover:not(:disabled) {
|
||||||
|
background:#e06b00;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity:.6;
|
||||||
|
cursor:default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size:.85em;
|
||||||
|
color:#888;
|
||||||
|
margin:0;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
text-align:center;
|
||||||
|
margin-top:16px;
|
||||||
|
font-size:.9em;
|
||||||
|
color:#666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link a {
|
||||||
|
color:#2196F3;
|
||||||
|
text-decoration:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background:#ffebee;
|
||||||
|
color:#c62828;
|
||||||
|
padding:10px 14px;
|
||||||
|
border-radius:8px;
|
||||||
|
margin-top:16px;
|
||||||
|
font-size:.9em;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
26
src/routes/register/+page.server.js
Normal file
26
src/routes/register/+page.server.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { createLocalUser } from '$lib/server/auth.js';
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async (event) => {
|
||||||
|
const data = await event.request.formData();
|
||||||
|
const username = data.get('username');
|
||||||
|
const email = String(data.get('email') ?? '');
|
||||||
|
const password = String(data.get('password') ?? '');
|
||||||
|
const confirm = String(data.get('confirm') ?? '');
|
||||||
|
|
||||||
|
if (!username || !email || !password)
|
||||||
|
return { error: 'Заполните все поля' };
|
||||||
|
if (password.length < 8)
|
||||||
|
return { error: 'Пароль должен быть не менее 8 символов' };
|
||||||
|
if (password !== confirm)
|
||||||
|
return { error: 'Пароли не совпадают' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
createLocalUser(username, email, password);
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, email, password };
|
||||||
|
},
|
||||||
|
};
|
||||||
212
src/routes/register/+page.svelte
Normal file
212
src/routes/register/+page.svelte
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
<script>
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { progressStorage } from '$lib/utils/storage';
|
||||||
|
|
||||||
|
export let form; //ошибки из page.server.js
|
||||||
|
|
||||||
|
let loading = false;
|
||||||
|
let showPass = false;
|
||||||
|
let showConfirm = false;
|
||||||
|
let email = '';
|
||||||
|
let password = '';
|
||||||
|
let confirm = '';
|
||||||
|
|
||||||
|
function handleEnhance() {
|
||||||
|
loading = true;
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'error' || (result.type === 'failure' && result.data?.error)) {
|
||||||
|
await update({ reset: false });
|
||||||
|
password = '';
|
||||||
|
confirm = '';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.type === 'success' && result.data?.success) {
|
||||||
|
//аккаунт создан, входим через Auth.js fetch
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/callback/local', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ email: result.data.email, password: result.data.password }),
|
||||||
|
});
|
||||||
|
await progressStorage.transferToDB();
|
||||||
|
window.location.href = res.url.includes('/login') ? '/login' : '/';
|
||||||
|
} catch {
|
||||||
|
window.location.href = '/login?registered=1';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await update({ reset: false });
|
||||||
|
password = '';
|
||||||
|
confirm = '';
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="card">
|
||||||
|
<a href="/" class="close-btn" title="На главную">✕</a>
|
||||||
|
<h1>Регистрация</h1>
|
||||||
|
|
||||||
|
<form method="POST" use:enhance={handleEnhance}>
|
||||||
|
<div class="form">
|
||||||
|
<input name="username" type="text" placeholder="Имя пользователя" required />
|
||||||
|
<input name="email" type="email" placeholder="Email"
|
||||||
|
bind:value={email} required />
|
||||||
|
<div class="pass-wrap">
|
||||||
|
<input name="password" type={showPass ? 'text' : 'password'}
|
||||||
|
placeholder="Пароль (мин. 8 символов)"
|
||||||
|
bind:value={password}
|
||||||
|
autocomplete="new-password" required />
|
||||||
|
<button type="button" class="eye" on:click={() => showPass = !showPass}>
|
||||||
|
{showPass ? '🙈' : '👁️'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="pass-wrap">
|
||||||
|
<input name="confirm" type={showConfirm ? 'text' : 'password'}
|
||||||
|
placeholder="Повторите пароль"
|
||||||
|
bind:value={confirm}
|
||||||
|
autocomplete="new-password" required />
|
||||||
|
<button type="button" class="eye" on:click={() => showConfirm = !showConfirm}>
|
||||||
|
{showConfirm ? '🙈' : '👁️'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" disabled={loading}>
|
||||||
|
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="error">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="link">Уже есть аккаунт? <a href="/login">Войти</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
min-height:100vh;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
background:#f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position:relative;
|
||||||
|
background:white;
|
||||||
|
border-radius:12px;
|
||||||
|
padding:40px;
|
||||||
|
width:100%;
|
||||||
|
max-width:420px;
|
||||||
|
box-shadow:0 4px 20px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
position:absolute;
|
||||||
|
top:14px;
|
||||||
|
right:14px;
|
||||||
|
font-size:1.1em;
|
||||||
|
color:#aaa;
|
||||||
|
text-decoration:none;
|
||||||
|
padding:4px 8px;
|
||||||
|
border-radius:50%;
|
||||||
|
transition:all .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color:#555;
|
||||||
|
background:#f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin:0 0 24px;
|
||||||
|
color:#1565c0;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
gap:12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width:100%;
|
||||||
|
padding:11px 14px;
|
||||||
|
border:1px solid #ddd;
|
||||||
|
border-radius:8px;
|
||||||
|
font-size:1em;
|
||||||
|
outline:none;
|
||||||
|
box-sizing:border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color:#2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-wrap {
|
||||||
|
position:relative;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass-wrap input {
|
||||||
|
padding-right:44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye {
|
||||||
|
position:absolute;
|
||||||
|
right:10px;
|
||||||
|
background:none;
|
||||||
|
border:none;
|
||||||
|
cursor:pointer;
|
||||||
|
font-size:1.1em;
|
||||||
|
padding:4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background:#2196F3;
|
||||||
|
color:white;
|
||||||
|
border:none;
|
||||||
|
padding:12px;
|
||||||
|
border-radius:8px;
|
||||||
|
cursor:pointer;
|
||||||
|
font-size:1em;
|
||||||
|
font-weight:500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background:#1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity:.6;
|
||||||
|
cursor:default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
text-align:center;
|
||||||
|
margin-top:16px;
|
||||||
|
font-size:.9em;
|
||||||
|
color:#666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link a {
|
||||||
|
color:#2196F3;
|
||||||
|
text-decoration:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background:#ffebee;
|
||||||
|
color:#c62828;
|
||||||
|
padding:10px 14px;
|
||||||
|
border-radius:8px;
|
||||||
|
margin-top:16px;
|
||||||
|
font-size:.9em;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue
Block a user