Compare commits

...
6 Commits
50 changed files with 10086 additions and 1 deletions
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+15 -1
View File
@@ -1,2 +1,16 @@
# learning_network_protocols
## Запуск
1. Для запуска перейдите в Command Prompt.
2. Запуск осуществляется из папки проекта.
3. Введите команду npm run dev
## Настройка AI-подсказок
В приложении используется AI для генерации подсказок к заданиям (OpenRouter API).
1. Зарегистрируйтесь на [openrouter.ai](https://openrouter.ai)
2. Перейдите в раздел **Keys****Create Key**
3. Скопируйте ключ — он показывается **только один раз**
4. Создайте файл `.env` в корне проекта:
OPENROUTER_API_KEY=sk-or-v1-ваш_ключ_здесь
Без этого файла приложение работает, но кнопка «Подсказка» будет недоступна.
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}
BIN
View File
Binary file not shown.
+4248
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "networking-tutor",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''"
},
"devDependencies": {
"@eslint/js": "^9.38.0",
"@sveltejs/adapter-auto": "^6.1.1",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/better-sqlite3": "^7.6.13",
"@types/eslint__js": "^8.42.3",
"@types/prettier": "^2.7.3",
"eslint": "^9.38.0",
"prettier": "^3.6.2",
"svelte": "^5.39.5",
"typescript-eslint": "^8.46.1",
"vite": "^7.1.7"
},
"dependencies": {
"@auth/sveltekit": "^1.11.2",
"better-sqlite3": "^12.9.0",
"drizzle-orm": "^0.45.2"
}
}
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+87
View 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
View 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);
}
);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+111
View File
@@ -0,0 +1,111 @@
<script>
import { writable } from 'svelte/store';
export let buffer = new Uint8Array([0x00]);
export let onBufferChange;
const bits = writable([]);
$: {
const value = buffer[0] || 0;
const newBits = [];
for (let i = 7; i >= 0; i--) {
newBits.push({
position: i,
isSet: !!(value & (1 << i))
});
}
bits.set(newBits);
}
function toggleBit(position) {
const currentValue = buffer[0] || 0;
const newValue = currentValue ^ (1 << position);
const newBuffer = new Uint8Array([newValue]);
onBufferChange(newBuffer);
}
</script>
<div class="bit-editor">
<h4>Редактор битов:</h4>
<div class="bit-positions">
<span class="label">Бит:</span>
{#each [7,6,5,4,3,2,1,0] as pos}
<span class="position">{pos}</span>
{/each}
</div>
<div class="bit-controls">
<span class="label">Значение:</span>
{#each $bits as bit}
<button
class:active={bit.isSet}
class="bit-toggle"
on:click={() => toggleBit(bit.position)}
>
{bit.isSet ? '1' : '0'}
</button>
{/each}
</div>
<div class="bit-help">
<small>Кликните на бит, чтобы переключить 0/1</small>
</div>
</div>
<style>
.bit-editor {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
margin: 16px 0;
}
.bit-positions, .bit-controls {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.label {
font-weight: bold;
min-width: 80px;
}
.position {
padding: 8px 12px;
background: #e0e0e0;
border-radius: 6px;
font-size: 0.9em;
min-width: 16px;
text-align: center;
}
.bit-toggle {
padding: 8px 12px;
border: 2px solid #2196F3;
background: white;
border-radius: 6px;
cursor: pointer;
min-width: 40px;
font-family: 'Courier New', monospace;
font-weight: bold;
transition: all 0.2s;
}
.bit-toggle.active {
background: #2196F3;
color: white;
}
.bit-toggle:hover {
transform: translateY(-2px);
}
.bit-help {
color: #666;
font-style: italic;
}
</style>
+354
View File
@@ -0,0 +1,354 @@
<script>
export let buffer;
export let onBufferChange;
export let startIndex = 0;
export let byteLength = 1; // 1, 2, 4, 6, 8
export let endianness = 'big'; // 'big' или 'little'
export let numberFormat = 'hex'; // 'bin', 'oct', 'dec', 'hex'
export let readOnlyRanges = [];
let inputValue = '';
let error = '';
function isByteReadOnly(index) {
return readOnlyRanges.some(range =>
index >= range.start && index <= range.end
);
}
$: {
if (buffer && startIndex + byteLength <= buffer.length) {
updateInputFromBuffer();
} else {
error = 'Выход за границы буфера';
}
}
function updateInputFromBuffer() {
try {
const bytes = buffer.slice(startIndex, startIndex + byteLength);
const value = bytesToBigInt(bytes, endianness);
inputValue = formatValueWithLeadingZeros(value);
error = '';
} catch (e) {
error = e.message;
}
}
function bytesToBigInt(bytes, endian) {
let result = 0n;
if (endian === 'big') {
for (let i = 0; i < bytes.length; i++) {
result = (result << 8n) | BigInt(bytes[i]);
}
} else {
for (let i = bytes.length - 1; i >= 0; i--) {
result = (result << 8n) | BigInt(bytes[i]);
}
}
return result;
}
function bigIntToBytes(num, length, endian) {
const bytes = new Uint8Array(length);
let temp = num;
if (endian === 'big') {
for (let i = length - 1; i >= 0; i--) {
bytes[i] = Number(temp & 0xFFn);
temp >>= 8n;
}
} else {
for (let i = 0; i < length; i++) {
bytes[i] = Number(temp & 0xFFn);
temp >>= 8n;
}
}
return bytes;
}
function handleInputChange(e) {
const value = e.target.value.trim().toUpperCase();
inputValue = value;
try {
let bigIntValue;
switch (numberFormat) {
case 'bin':
const binValue = value.replace(/^0b|[\s_]/g, '');
if (!/^[01]*$/.test(binValue)) {
throw new Error('Только 0 и 1 для binary');
}
bigIntValue = binValue === '' ? 0n : BigInt('0b' + binValue);
break;
case 'oct':
const octValue = value.replace(/^0o|[\s_]/g, '');
if (!/^[0-7]*$/.test(octValue)) {
throw new Error('Только цифры 0-7 для octal');
}
bigIntValue = octValue === '' ? 0n : BigInt('0o' + octValue);
break;
case 'dec':
const decValue = value.replace(/[\s_]/g, '');
if (!/^\d*$/.test(decValue)) {
throw new Error('Только цифры для decimal');
}
bigIntValue = decValue === '' ? 0n : BigInt(decValue);
break;
case 'hex':
const hexValue = value.replace(/^0x|[\s_]/g, '');
if (!/^[0-9A-F]*$/.test(hexValue)) {
throw new Error('Только hex цифры (0-9, A-F)');
}
bigIntValue = hexValue === '' ? 0n : BigInt('0x' + hexValue);
break;
default:
throw new Error('Неизвестный формат');
}
const maxBits = 8n * BigInt(byteLength);
const maxValue = (1n << maxBits) - 1n;
if (bigIntValue > maxValue) {
throw new Error(`Максимальное значение: ${formatValueWithLeadingZeros(maxValue)}`);
}
const newBytes = bigIntToBytes(bigIntValue, byteLength, endianness);
const newBuffer = new Uint8Array(buffer);
for (let i = 0; i < byteLength; i++) {
const byteIndex = startIndex + i;
if (isByteReadOnly(byteIndex)) {
throw new Error(`Байт ${byteIndex} защищен от записи`);
}
newBuffer[byteIndex] = newBytes[i];
}
onBufferChange(newBuffer);
error = '';
inputValue = formatValueWithLeadingZeros(bigIntValue);
} catch (e) {
error = e.message;
}
}
function formatValueWithLeadingZeros(val) {
switch (numberFormat) {
case 'bin':
return val.toString(2).padStart(byteLength * 8, '0');
case 'oct':
const octDigits = Math.ceil(byteLength * 8 / 3);
return val.toString(8).padStart(octDigits, '0');
case 'dec':
return val.toString(10);
case 'hex':
return val.toString(16).toUpperCase().padStart(byteLength * 2, '0');
default:
return val.toString(16).toUpperCase().padStart(byteLength * 2, '0');
}
}
function handleFormatChange(e) {
numberFormat = e.target.value;
updateInputFromBuffer();
}
function handleEndiannessChange(e) {
endianness = e.target.value;
updateInputFromBuffer();
}
</script>
<div class="byte-editor">
<h4>Редактор {byteLength} байт</h4>
<div class="controls">
<div class="control-group">
<label>Формат:</label>
<select value={numberFormat} on:change={handleFormatChange}>
<option value="hex">Hex</option>
<option value="dec">Decimal</option>
<option value="bin">Binary</option>
<option value="oct">Octal</option>
</select>
</div>
{#if byteLength > 1}
<div class="control-group">
<label>Порядок байт:</label>
<select value={endianness} on:change={handleEndiannessChange}>
<option value="big">Big Endian</option>
<option value="little">Little Endian</option>
</select>
</div>
{/if}
</div>
<div class="value-input">
<label>Значение:</label>
<div class="input-wrapper">
<input
type="text"
bind:value={inputValue}
on:input={handleInputChange}
class:error={error}
placeholder="Введите значение"
/>
</div>
{#if error}
<div class="error-text">{error}</div>
{/if}
</div>
<div class="byte-preview">
<div class="preview-header">Байты в памяти:</div>
<div class="bytes">
{#if buffer && startIndex + byteLength <= buffer.length}
{#each Array.from({length: byteLength}, (_, i) => i) as i}
<div class="byte">
<span class="index">[{startIndex + i}]</span>
<span class="hex">0x{buffer[startIndex + i].toString(16).padStart(2, '0').toUpperCase()}</span>
<span class="dec">({buffer[startIndex + i]})</span>
</div>
{/each}
{/if}
</div>
</div>
</div>
<style>
.byte-editor {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
margin: 16px 0;
min-width: 400px;
}
.controls {
display: flex;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 120px;
}
.control-group label {
font-weight: bold;
font-size: 0.9em;
white-space: nowrap;
}
.control-group select {
padding: 6px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: 'Courier New', monospace;
min-width: 100px;
}
.value-input {
margin-bottom: 16px;
}
.value-input label {
display: block;
font-weight: bold;
margin-bottom: 4px;
}
.input-wrapper {
width: 100%;
}
.value-input input {
width: 100%;
min-width: 200px;
padding: 8px 12px;
border: 2px solid #2196F3;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 1em;
box-sizing: border-box;
}
.value-input input.error {
border-color: #f44336;
background-color: #ffebee;
}
.error-text {
color: #f44336;
font-size: 0.9em;
margin-top: 4px;
min-height: 1.2em;
}
.byte-preview {
background: #f5f5f5;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
}
.preview-header {
font-weight: bold;
margin-bottom: 8px;
color: #666;
white-space: nowrap;
}
.bytes {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.byte {
background: white;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
min-width: 80px;
text-align: center;
flex-shrink: 0;
}
.byte .index {
display: block;
color: #666;
font-size: 0.8em;
margin-bottom: 2px;
}
.byte .hex {
display: block;
font-weight: bold;
font-size: 1em;
}
.byte .dec {
display: block;
color: #2196F3;
font-size: 0.9em;
}
</style>
+180
View File
@@ -0,0 +1,180 @@
<script>
import ToolSelector from './ToolSelector.svelte';
import { onMount } from 'svelte';
export let buffer;
export let onBufferChange;
export let readOnlyRanges = []; // [{start, end}]
let selectedByteIndex = -1;
let showToolSelector = false;
let toolPosition = { x: 0, y: 0 };
let containerRef;
onMount(() => {
if (containerRef) {
updateCenterPosition();
}
});
function isByteReadOnly(index) {
return readOnlyRanges.some(range =>
index >= range.start && index <= range.end
);
}
function updateCenterPosition() {
if (!containerRef) return;
const rect = containerRef.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
toolPosition = {
x: Math.max(20, centerX - 200),
y: Math.max(20, centerY - 150)
};
}
function selectByte(index, event) {
if (isByteReadOnly(index)) return;
updateCenterPosition();
selectedByteIndex = index;
if (!showToolSelector) {
showToolSelector = true;
}
}
function clearBuffer() {
let newBuffer = new Uint8Array(buffer.length);
readOnlyRanges.forEach(range => {
for (let i = range.start; i <= range.end; i++) {
if (i < buffer.length) {
newBuffer[i] = buffer[i];
}
}
});
onBufferChange(newBuffer);
selectedByteIndex = -1;
showToolSelector = false;
}
$: hexString = Array.from(buffer)
.map((byte, index) => {
const hex = byte.toString(16).padStart(2, '0').toUpperCase();
return { hex, index };
});
</script>
<div class="hex-editor">
<h3>Редактор буфера:</h3>
<div class="hex-view">
{#each hexString as {hex, index}}
<button
class:selected={selectedByteIndex === index}
class:read-only={isByteReadOnly(index)}
class="byte-button"
on:click={(e) => selectByte(index, e)}
>
{'0x'+hex}
</button>
{/each}
</div>
<div class="controls">
<button on:click={clearBuffer} class="clear-button">
Очистить все
</button>
</div>
{#if showToolSelector && selectedByteIndex >= 0 && !isByteReadOnly(selectedByteIndex)}
<ToolSelector
bind:show={showToolSelector}
buffer={buffer}
onBufferChange={onBufferChange}
startIndex={selectedByteIndex}
position={toolPosition}
{readOnlyRanges}
/>
{/if}
</div>
<style>
.hex-editor {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin: 16px 0;
}
.hex-view {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
padding: 16px;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
}
.byte-button {
padding: 8px 12px;
border: 2px solid #2196F3;
background: white;
border-radius: 6px;
cursor: pointer;
font-family: 'Courier New', monospace;
font-weight: bold;
transition: all 0.2s;
position: relative;
min-width: 70px;
}
.byte-button:hover {
background: #e3f2fd;
transform: translateY(-2px);
}
.byte-button.selected {
background: #2196F3;
color: white;
border-color: #1976D2;
}
.byte-button.read-only {
border-color: #9e9e9e;
background: #f5f5f5;
color: #757575;
cursor: not-allowed;
}
.byte-button.read-only:hover {
background: #f5f5f5;
transform: none;
}
.controls {
margin-top: 16px;
}
.clear-button {
padding: 8px 16px;
background: #ffebee;
border: 2px solid #f44336;
border-radius: 6px;
color: #c62828;
cursor: pointer;
font-weight: bold;
}
.clear-button:hover {
background: #ffcdd2;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<script>
export let data = new Uint8Array([]);
export let highlightedBytes = [];
$: hexString = Array.from(data)
.map((byte, index) => {
const hex = byte.toString(16).padStart(2, '0').toUpperCase();
return { hex, index };
});
</script>
<div class="hex-viewer">
<div class="bytes">
{#each hexString as {hex, index}}
<span
class:highlighted={highlightedBytes.includes(index)}
class="byte"
>
{'0x'+hex}
</span>
{/each}
</div>
<div class="info">
Размер: {data.length} байт(а)
{#if data.length === 1}
| Десятичное: {data[0]} | Двоичное: {data[0].toString(2).padStart(8, '0')}
{/if}
</div>
</div>
<style>
.hex-viewer {
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
background: #f9f9f9;
font-family: 'Courier New', monospace;
margin: 16px 0;
}
.bytes {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.byte {
padding: 4px 8px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
transition: all 0.2s;
}
.byte.highlighted {
background: #4CAF50;
color: white;
border-color: #388E3C;
}
.info {
font-size: 0.9em;
color: #666;
border-top: 1px solid #eee;
padding-top: 8px;
}
</style>
+366
View File
@@ -0,0 +1,366 @@
<!--принимает buffer (Uint8Array, закодированный TextEncoder текст)
и вызывает onBufferChange с новым закодированным текстом-->
<script>
import { onMount } from 'svelte';
export let buffer = new Uint8Array([]);
export let onBufferChange = () => {};
export let readOnlyRanges = []; //для совместимости
let text = '';
let parsed = null;
let lineCount = 1;
onMount(() => {
if (buffer.length > 0) {
text = new TextDecoder().decode(buffer);
parsed = parseRequest(text);
} else {
text = 'GET / HTTP/1.1\nHost: \n\n';
sync();
}
updateLineCount();
});
function sync() {
parsed = parseRequest(text);
onBufferChange(new TextEncoder().encode(text));
}
function handleInput() {
updateLineCount();
sync();
}
function updateLineCount() {
lineCount = (text.match(/\n/g) ?? []).length + 1;
}
function parseRequest(raw) {
if (!raw.trim()) return null;
const normalized = raw.replace(/\r\n/g, '\n');
const lines = normalized.split('\n');
const rlMatch = lines[0]?.match(/^(\S+)\s+(\S+)\s+(\S+)$/);
if (!rlMatch) {
return { error: 'Некорректная строка запроса. Ожидается: МЕТОД /путь HTTP/1.1' };
}
const [, method, path, version] = rlMatch;
const headers = {};
let bodyStart = lines.length;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '') {
bodyStart = i + 1;
break;
}
const colon = lines[i].indexOf(':');
if (colon > 0) {
const key = lines[i].slice(0, colon).trim();
const value = lines[i].slice(colon + 1).trim();
headers[key.toLowerCase()] = { key, value };
}
}
const bodyLines = lines.slice(bodyStart);
const body = bodyLines.join('\n').trim();
const hasBlankLine = normalized.includes('\n\n');
return { method, path, version, headers, body, hasBlankLine, error: null };
}
function methodColor(method) {
const colors = {
GET: '#4caf50',
POST: '#2196f3',
PUT: '#ff9800',
PATCH: '#9c27b0',
DELETE: '#f44336',
HEAD: '#607d8b',
};
return colors[method?.toUpperCase()] ?? '#555';
}
function versionOk(v) {
return /^HTTP\/\d+\.\d+$/.test(v ?? '');
}
function headerEntries(headers) {
return Object.values(headers ?? {});
}
//примерное количество строк для textarea
$: rows = Math.max(lineCount + 2, 8);
</script>
<div class="http-editor">
<!--textarea-->
<div class="editor-area">
<div class="line-numbers" aria-hidden="true">
{#each { length: lineCount } as _, i}
<span>{i + 1}</span>
{/each}
</div>
<textarea
bind:value={text}
on:input={handleInput}
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
placeholder="Введите HTTP-запрос..."
{rows}
></textarea>
</div>
<!--разбивка-->
{#if parsed}
<div class="parsed-view">
<h4>Разбор запроса</h4>
{#if parsed.error}
<div class="parse-error">{parsed.error}</div>
{:else}
<!--строка запроса-->
<div class="section">
<div class="section-title">Строка запроса</div>
<div class="request-line">
<span class="badge method" style="background:{methodColor(parsed.method)}">
{parsed.method}
</span>
<span class="badge path">{parsed.path}</span>
<span class="badge version" class:ok={versionOk(parsed.version)} class:bad={!versionOk(parsed.version)}>
{parsed.version}
</span>
</div>
</div>
<!--заголовки-->
<div class="section">
<div class="section-title">
Заголовки
<span class="count">({headerEntries(parsed.headers).length})</span>
</div>
{#if headerEntries(parsed.headers).length === 0}
<div class="empty-hint">— нет заголовков</div>
{:else}
<table class="headers-table">
{#each headerEntries(parsed.headers) as h}
<tr>
<td class="hkey">{h.key}</td>
<td class="hval">{h.value}</td>
</tr>
{/each}
</table>
{/if}
</div>
<!--разделитель-->
<div class="section">
<div class="section-title">Разделитель (пустая строка)</div>
{#if parsed.hasBlankLine}
<span class="ok-badge">✓ присутствует</span>
{:else}
<span class="bad-badge">✗ отсутствует — заголовки не отделены от тела</span>
{/if}
</div>
<!--тело-->
<div class="section">
<div class="section-title">
Тело запроса
{#if parsed.body}
<span class="count">({new TextEncoder().encode(parsed.body).length} байт)</span>
{/if}
</div>
{#if parsed.body}
<pre class="body-preview">{parsed.body}</pre>
{:else}
<div class="empty-hint">— тело отсутствует</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<style>
.http-editor {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
margin: 16px 0;
font-family: 'Courier New', monospace;
}
/*textarea area*/
.editor-area {
display: flex;
background: #1e1e1e;
}
.line-numbers {
display: flex;
flex-direction: column;
padding: 12px 8px;
background: #252526;
color: #666;
font-size: 0.8em;
line-height: 1.6;
text-align: right;
user-select: none;
min-width: 36px;
border-right: 1px solid #333;
}
.line-numbers span {
display: block;
}
textarea {
flex: 1;
background: #1e1e1e;
color: #d4d4d4;
border: none;
outline: none;
padding: 12px;
font-family: 'Courier New', Consolas, monospace;
font-size: 0.9em;
line-height: 1.6;
resize: vertical;
min-height: 140px;
tab-size: 4;
white-space: pre;
overflow-x: auto;
}
textarea::placeholder {
color: #555;
}
/*parsed view*/
.parsed-view {
background: #fafafa;
border-top: 1px solid #e0e0e0;
padding: 16px 20px;
}
.parsed-view h4 {
margin: 0 0 14px;
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #888;
font-weight: 600;
}
.section {
margin-bottom: 14px;
}
.section-title {
font-size: 0.75em;
font-weight: bold;
color: #555;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 6px;
}
.count {
font-weight: normal;
color: #999;
font-size: 0.9em;
text-transform: none;
}
.parse-error {
background: #fff3e0;
border-left: 4px solid #ff9800;
padding: 8px 12px;
border-radius: 4px;
font-family: sans-serif;
font-size: 0.85em;
color: #e65100;
}
/*строка запроса*/
.request-line {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.badge {
padding: 3px 10px;
border-radius: 4px;
font-size: 0.85em;
font-weight: bold;
}
.badge.method {
color: white;
}
.badge.path {
background: #f0f0f0;
color: #333;
font-weight: normal;
word-break: break-all;
}
.badge.version.ok { background: #e8f5e9; color: #2e7d32; }
.badge.version.bad { background: #ffebee; color: #c62828; }
/*заголовки*/
.headers-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85em;
}
.headers-table tr:nth-child(even) { background: #f5f5f5; }
.hkey {
padding: 4px 10px 4px 0;
color: #1976d2;
font-weight: bold;
white-space: nowrap;
vertical-align: top;
min-width: 140px;
}
.hval {
padding: 4px 0;
color: #333;
word-break: break-all;
}
/*разделитель/тело*/
.ok-badge { color: #2e7d32; font-size: 0.85em; }
.bad-badge { color: #c62828; font-size: 0.85em; }
.empty-hint {
color: #aaa;
font-size: 0.85em;
font-style: italic;
}
.body-preview {
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 10px;
font-size: 0.85em;
margin: 0;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
color: #333;
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<script>
export let lesson;
</script>
<div class="lesson-layout">
<header class="lesson-header">
<div class="header-content">
<a href="/" class="back-button">← Назад к урокам</a>
<h1>{lesson.title}</h1>
<div class="meta">
<span class="category">{lesson.category}</span>
<span class="difficulty">{lesson.difficulty}</span>
</div>
</div>
</header>
<div class="lesson-body">
<section class="theory-section">
<h2>Теория</h2>
<div class="theory-content">
{@html lesson.theory}
</div>
</section>
<section class="practice-section">
<h2>Практика</h2>
<slot></slot>
</section>
</div>
</div>
<style>
.lesson-layout {
min-height: 100vh;
background: white;
}
.lesson-header {
background: #2196F3;
color: white;
padding: 20px;
margin-bottom: 0;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
}
.back-button {
color: white;
text-decoration: none;
font-size: 0.9em;
margin-bottom: 10px;
display: inline-block;
}
.back-button:hover {
text-decoration: underline;
}
.lesson-header h1 {
margin: 0 0 8px 0;
}
.meta {
display: flex;
gap: 12px;
}
.category, .difficulty {
background: rgba(255,255,255,0.2);
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8em;
}
.lesson-body {
max-width: 1000px;
margin: 0 auto;
padding: 30px 20px;
}
.theory-section {
margin-bottom: 40px;
}
/*секция теории*/
.theory-section h2 {
color: #2196F3;
border-bottom: 2px solid #2196F3;
padding-bottom: 10px;
margin-bottom: 20px;
}
.theory-content {
line-height: 1.6;
background: #ffffff;
padding: 15px;
border-radius: 8px;
border-left: 4px solid #51adf8;
}
.theory-content {
line-height: 1.6;
color: #333;
}
/*секция практики*/
.practice-section h2 {
color: rgb(234, 88, 9);
border-bottom: 2px solid #FF9800;
padding-bottom: 10px;
margin-bottom: 20px;
}
@media (max-width: 768px) {
.lesson-body {
padding: 20px 15px;
}
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<script>
export let message = '';
export let type = 'success'; //'success' или 'error'
export let duration = 3000;
export let onClose;
let isVisible = false;
import { onMount, onDestroy } from 'svelte';
onMount(() => {
setTimeout(() => {
isVisible = true;
}, 100);
if (duration > 0) {
const timer = setTimeout(() => {
close();
}, duration);
onDestroy(() => clearTimeout(timer));
}
});
function close() {
isVisible = false;
setTimeout(() => {
onClose && onClose();
}, 300);
}
</script>
{#if message}
<div class="notification-container">
<div class:visible={isVisible} class="notification {type}">
<div class="notification-content">
{#if type === 'success'}
<span class="icon"></span>
{:else}
<span class="icon"></span>
{/if}
<span class="text">{message}</span>
</div>
<button class="close-button" on:click={close}>×</button>
</div>
</div>
{/if}
<style>
.notification-container {
position: fixed;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
z-index: 1000;
pointer-events: none;
}
.notification {
background: white;
border-radius: 8px;
padding: 16px 20px;
margin: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: flex;
align-items: center;
gap: 12px;
max-width: 400px;
transform: translateY(-100px);
opacity: 0;
transition: all 0.3s ease;
pointer-events: all;
}
.notification.visible {
transform: translateY(0);
opacity: 1;
}
.notification.success {
border-left: 4px solid #4CAF50;
}
.notification.error {
border-left: 4px solid #f44336;
}
.notification-content {
display: flex;
align-items: center;
gap: 8px;
flex-grow: 1;
}
.icon {
font-size: 1.2em;
}
.text {
font-weight: 500;
}
.close-button {
background: none;
border: none;
font-size: 1.5em;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.close-button:hover {
color: #000;
}
</style>
+261
View File
@@ -0,0 +1,261 @@
<script>
import ByteEditor from './ByteEditor.svelte';
import BitEditor from './BitEditor.svelte';
export let buffer;
export let onBufferChange;
export let startIndex = 0;
export let show = false;
export let readOnlyRanges = [];
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) {
for (let i = 0; i < tool.bytes; i++) {
const byteIndex = startIndex + i;
if (byteIndex >= buffer.length) return false;
const isReadOnly = readOnlyRanges.some(range =>
byteIndex >= range.start && byteIndex <= range.end
);
if (isReadOnly) return false;
}
return true;
}
$: availableTools = getAvailableTools();
$: if (availableTools.length > 0 && !availableTools.find(t => t.id === selectedTool)){
selectedTool = availableTools[0].id;
}
function getAvailableTools() {
const tools = [
{ id: 'bit', name: 'Битовый редактор', bytes: 1 },
{ id: 'byte', name: '1 байт', bytes: 1 },
{ id: '2bytes', name: '2 байта', bytes: 2 },
{ id: '4bytes', name: '4 байта', bytes: 4 },
{ id: '6bytes', name: '6 байта', bytes: 6 },
{ id: '8bytes', name: '8 байт', bytes: 8 }
];
return tools.filter(tool =>
startIndex + tool.bytes <= buffer.length &&
isToolValid(tool, startIndex)
);
}
function selectTool(toolId) {
selectedTool = toolId;
}
$: selectedToolData = availableTools.find(t => t.id === selectedTool);
</script>
<svelte:window on:mousemove={onMove} on:mouseup={stopDrag} />
{#if show}
<div
bind:this={popupEl}
class="popup"
class:dragging
style={popupStyle}
role="dialog"
>
<!--заголовок (тянем за него)-->
<div class="popup-header" on:mousedown={startDrag} role="presentation" tabindex="-1">
<span class="drag-icon"></span>
<span class="title">Редактирование байта {startIndex}</span>
<button class="close-x" on:click={() => show = false}>✕</button>
</div>
<!--выбор инструмента-->
<div class="tools-row">
{#each availableTools as tool}
<button
class="tool-btn"
class:active={selectedTool === tool.id}
on:click={() => selectedTool = tool.id}
>{tool.name}</button>
{/each}
</div>
<div class="content">
{#if selectedTool === 'bit'}
<BitEditor
buffer={new Uint8Array([buffer[startIndex]])}
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>
{/if}
<style>
.popup {
position: fixed;
z-index: 1000;
background: white;
border: 2px solid #2196F3;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
min-width: 380px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.popup.dragging { cursor: grabbing; box-shadow: 0 12px 40px rgba(0,0,0,0.3); }
/*заголовок*/
.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;
}
.close-x {
background: none;
border: none;
cursor: pointer;
color: #aaa;
font-size: 1em;
padding: 3px 7px;
border-radius: 50%;
line-height: 1;
transition: all 0.15s;
}
.close-x:hover {
color: #f44336;
background: #ffebee;
}
/*инструменты*/
.tools-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 12px 14px;
border-bottom: 1px solid #f0f0f0;
}
.tool-btn {
padding: 5px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 0.82em;
transition: all 0.15s;
color: #555;
}
.tool-btn:hover {
border-color: #90caf9;
color: #1565c0;
background: #f0f7ff;
}
.tool-btn.active {
background: #2196F3;
color: white;
border-color: #1976D2;
}
.content {
overflow-y: auto;
padding: 12px 14px;
flex: 1;
}
.footer {
padding: 10px 14px;
text-align: center;
border-top: 1px solid #f0f0f0;
}
.close-btn {
padding: 7px 22px;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 6px;
cursor: pointer;
font-size: 0.9em;
transition: background 0.15s;
}
.close-btn:hover {
background: #e0e0e0;
}
</style>
+286
View File
@@ -0,0 +1,286 @@
<!--универсальный компонент "Wireshark"
принимает описание полей из данных урока (lesson.wiresharkFields)-->
<script>
import {
formatIPv4Address,
formatIPv4Protocol,
formatVersionIHL,
formatFlagsFragment,
} from '$lib/utils/protocols/ipv4.js';
import {
formatPort,
formatSeqAck,
formatDataOffset,
formatTCPFlags,
formatWindowSize,
} from '$lib/utils/protocols/tcp.js';
import { formatUDPLength } from '$lib/utils/protocols/udp.js';
import {
formatDNSFlags,
formatDNSQType,
formatDNSQClass,
formatDNSQName,
} from '$lib/utils/protocols/dns.js';
export let data = new Uint8Array([]);
export let fields = [];
export let title = 'Структура пакета';
$: fieldValues = fields.map(field => ({
...field,
bytes: data.slice(field.start, field.start + field.length),
}));
function formatBytes(bytes, format) {
if (!bytes || bytes.length === 0) return 'N/A';
switch (format) {
case 'mac':
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
.join(':');
case 'ip':
return formatIPv4Address(bytes);
case 'decimal': {
let val = 0;
for (const b of bytes) val = (val << 8) | b;
return String(val);
}
case 'ethertype': {
const val = (bytes[0] << 8) | bytes[1];
const names = { 0x0800: 'IPv4', 0x0806: 'ARP', 0x86dd: 'IPv6' };
const name = names[val] ?? 'Unknown';
return `${name} (0x${val.toString(16).padStart(4, '0').toUpperCase()})`;
}
case 'ip_protocol':
return formatIPv4Protocol(bytes[0]);
case 'version_ihl':
return formatVersionIHL(bytes[0]);
case 'flags_fragment':
return formatFlagsFragment(bytes);
case 'port':
return formatPort(bytes);
case 'seq_ack':
return formatSeqAck(bytes);
case 'tcp_data_offset':
return formatDataOffset(bytes[0]);
case 'tcp_flags':
return formatTCPFlags(bytes[0]);
case 'window_size':
return formatWindowSize(bytes);
case 'udp_length':
return formatUDPLength(bytes);
case 'dns_flags':
return formatDNSFlags(bytes);
case 'dns_qtype':
return formatDNSQType(bytes);
case 'dns_qclass':
return formatDNSQClass(bytes);
case 'dns_qname':
return formatDNSQName(bytes);
case 'hex':
default:
return (
'0x' +
Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
.join(' ')
);
}
}
//размеры frame-layout
const PX_PER_BYTE = 28;
const MIN_CELL_PX = 56;
$: totalBytesCount = fieldValues.length
? fieldValues[fieldValues.length - 1].start + fieldValues[fieldValues.length - 1].length
: 0;
$: layoutMinWidth = Math.max(totalBytesCount * PX_PER_BYTE, 480);
</script>
<div class="wireshark-view">
<h3>{title}</h3>
<!--список полей Wireshark-->
<div class="fields">
{#each fieldValues as fv (fv.name)}
<div class="field">
<div class="field-label">
<span class="field-name">{fv.name}:</span>
<span class="field-range">
{fv.length === 1
? `байт ${fv.start}`
: `байты ${fv.start}${fv.start + fv.length - 1}`}
</span>
</div>
<div class="value" style="border-left: 4px solid {fv.color ?? '#2196F3'}">
{formatBytes(fv.bytes, fv.format)}
</div>
{#if fv.description}
<span class="field-hint">{fv.description}</span>
{/if}
</div>
{/each}
</div>
<!--визуальная схема пакета-->
{#if fieldValues.length > 0}
<div class="frame-preview">
<h4>Структура пакета ({totalBytesCount} байт):</h4>
<div class="frame-scroll">
<div class="frame-layout" style="min-width: {layoutMinWidth}px">
{#each fieldValues as fv (fv.name)}
{@const cellPx = Math.max(fv.length * PX_PER_BYTE, MIN_CELL_PX)}
<div class="frame-field"
style="width: {cellPx}px; flex: 0 0 {cellPx}px; background: {fv.color ?? '#e3f2fd'};"
title="{fv.name} ({fv.length}B) — {formatBytes(fv.bytes, fv.format)}"
>
<span class="frame-field-name">{fv.name}</span>
<small>{fv.length}B</small>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
<style>
.wireshark-view {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin: 16px 0;
background: #fdfdfd;
}
.wireshark-view h3 {
margin: 0 0 16px;
color: #333;
font-size: 1em;
font-weight: bold;
}
.fields {
margin-bottom: 20px;
}
.field {
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.field-label {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.field-name {
font-weight: bold;
color: #333;
}
.field-range {
font-size: 0.75em;
color: #999;
font-family: 'Courier New', monospace;
white-space: nowrap;
}
.value {
background: white;
padding: 7px 12px;
border-radius: 4px;
border: 1px solid #ddd;
font-family: 'Courier New', monospace;
font-size: 0.9em;
word-break: break-all;
}
.field-hint {
font-size: 0.78em;
color: #777;
font-style: italic;
}
/*frame layout*/
.frame-preview {
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.frame-preview h4 {
margin: 0 0 10px;
color: #555;
font-size: 0.9em;
}
/*горизонтальный скролл*/
.frame-scroll {
overflow-x: auto;
padding-bottom: 6px;
}
.frame-layout {
display: flex;
border: 1px solid #ccc;
border-radius: 4px;
overflow: hidden;
}
.frame-field {
flex-shrink: 0;
padding: 8px 4px;
text-align: center;
border-right: 1px solid #ccc;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
min-height: 54px;
}
.frame-field:last-child {
border-right: none;
}
.frame-field-name {
display: block;
font-weight: bold;
font-size: 0.7em;
line-height: 1.2;
word-break: break-word;
hyphens: auto;
}
.frame-field small {
color: #555;
font-size: 0.65em;
}
</style>
+391
View File
@@ -0,0 +1,391 @@
import { theory } from './theory.js';
export const lessons = [
{
id: 1,
slug: 'bit-manipulation',
title: 'Бит и байт - фундаментальные понятия',
category: 'Основы',
difficulty: 'Начинающий',
component: 'BitEditor',
additionalComponents: ['HexViewer'],
theory: theory['bit-manipulation'],
taskTemplate: { type: 'bit-set' },
},
{
id: 2,
slug: 'mac-address',
title: 'MAC адреса - редактирование 6 байт',
category: 'Ethernet',
difficulty: 'Начинающий',
component: 'HexEditor',
theory: theory['mac-address'],
taskTemplate: { type: 'mac-set' },
},
{
id: 3,
slug: 'ethernet-frame',
title: 'Структура Ethernet кадра',
category: 'Ethernet',
difficulty: 'Начинающий',
component: 'HexEditor',
additionalComponents: ['WiresharkView'],
wiresharkFields: [
{ name: 'Destination MAC', start: 0, length: 6, format: 'mac', color: '#e3f2fd' },
{ name: 'Source MAC', start: 6, length: 6, format: 'mac', color: '#fff3e0' },
{ name: 'EtherType', start: 12, length: 2, format: 'ethertype', color: '#e8f5e8' },
{ name: 'Data', start: 14, length: 46, format: 'hex', color: '#f3e5f5' },
{ name: 'FCS', start: 60, length: 4, format: 'hex', color: '#ffebee' }
],
wiresharkTitle: 'Структура Ethernet кадра',
readOnlyRanges: [
{ start: 14, end: 59 }, //Data (46 байт)
{ start: 60, end: 63 } //FCS (4 байта) - рассчитывается автоматически
],
theory: theory['ethernet-frame'],
taskTemplate: { type: 'ethernet-frame' },
},
{
id: 4,
slug: 'ipv4-header',
title: 'Структура IPv4-заголовка',
category: 'IPv4',
difficulty: 'Средний',
component: 'HexEditor',
additionalComponents: ['WiresharkView'],
wiresharkTitle: 'Структура IPv4-заголовка',
wiresharkFields: [
{
name: 'Version + IHL',
start: 0, length: 1,
format: 'version_ihl',
color: '#e3f2fd',
description: 'Версия протокола (4) и длина заголовка в 32-битных словах'
},
{
name: 'DSCP + ECN',
start: 1, length: 1,
format: 'hex',
color: '#e8eaf6',
description: 'Приоритет трафика и уведомление о перегрузке'
},
{
name: 'Total Length',
start: 2, length: 2,
format: 'decimal',
color: '#e8f5e9',
description: 'Общая длина пакета (заголовок + данные), байт'
},
{
name: 'Identification',
start: 4, length: 2,
format: 'hex',
color: '#fff3e0',
description: 'Идентификатор пакета (для сборки фрагментов)'
},
{
name: 'Flags + Fragment Offset',
start: 6, length: 2,
format: 'flags_fragment',
color: '#fce4ec',
description: 'Флаги фрагментации DF/MF и смещение фрагмента'
},
{
name: 'TTL',
start: 8, length: 1,
format: 'decimal',
color: '#f3e5f5',
description: 'Время жизни пакета — уменьшается на 1 на каждом маршрутизаторе'
},
{
name: 'Protocol',
start: 9, length: 1,
format: 'ip_protocol',
color: '#e0f7fa',
description: 'Протокол вышележащего уровня (TCP=6, UDP=17, ICMP=1)'
},
{
name: 'Header Checksum',
start: 10, length: 2,
format: 'hex',
color: '#f1f8e9',
description: 'Контрольная сумма заголовка (пересчитывается автоматически)'
},
{
name: 'Source IP',
start: 12, length: 4,
format: 'ip',
color: '#fff8e1',
description: 'IP-адрес отправителя'
},
{
name: 'Destination IP',
start: 16, length: 4,
format: 'ip',
color: '#fbe9e7',
description: 'IP-адрес получателя'
},
],
//read-only диапазоны
//байты 0-7: version, IHL, DSCP, total length, identification, flags (установлены заранее)
//байты 10-11: checksum (пересчитывается автоматически в bufferProcessor)
readOnlyRanges: [
{ start: 0, end: 7 },
{ start: 10, end: 11 },
],
theory: theory['ipv4-header'],
taskTemplate: { type: 'ipv4-addresses' },
},
{
id: 5,
slug: 'ipv4-ttl',
title: 'TTL - время жизни пакета',
category: 'IPv4',
difficulty: 'Средний',
component: 'HexEditor',
additionalComponents: ['WiresharkView'],
wiresharkTitle: 'IPv4: поле TTL',
wiresharkFields: [
{ name: 'Version + IHL', start: 0, length: 1, format: 'version_ihl', color: '#e3f2fd', description: '' },
{ name: 'DSCP + ECN', start: 1, length: 1, format: 'hex', color: '#e8eaf6', description: '' },
{ name: 'Total Length', start: 2, length: 2, format: 'decimal', color: '#e8f5e9', description: 'Общая длина пакета' },
{ name: 'Identification', start: 4, length: 2, format: 'hex', color: '#fff3e0', description: '' },
{ name: 'Flags + Frag. Offset', start: 6, length: 2, format: 'flags_fragment', color: '#fce4ec', description: '' },
{ name: 'TTL', start: 8, length: 1, format: 'decimal', color: '#f3e5f5', description: 'Уменьшается на 1 на каждом маршрутизаторе' },
{ name: 'Protocol', start: 9, length: 1, format: 'ip_protocol', color: '#e0f7fa', description: '' },
{ name: 'Header Checksum', start: 10, length: 2, format: 'hex', color: '#f1f8e9', description: 'Рассчитывается автоматически' },
{ name: 'Source IP', start: 12, length: 4, format: 'ip', color: '#fff8e1', description: '' },
{ name: 'Destination IP', start: 16, length: 4, format: 'ip', color: '#fbe9e7', description: '' },
],
readOnlyRanges: [
{ start: 0, end: 7 }, //фиксированные поля заголовка
{ start: 9, end: 11 }, //protocol + checksum
{ start: 12, end: 19 }, //IP-адреса (уже заданы)
],
theory: theory['ipv4-ttl'],
taskTemplate: { type: 'ipv4-ttl' },
},
{
id: 6,
slug: 'ipv4-fragmentation',
title: 'Фрагментация IPv4',
category: 'IPv4',
difficulty: 'Продвинутый',
component: 'HexEditor',
additionalComponents: ['WiresharkView'],
wiresharkTitle: 'IPv4: флаги и фрагментация',
wiresharkFields: [
{ name: 'Version + IHL', start: 0, length: 1, format: 'version_ihl', color: '#e3f2fd', description: '' },
{ name: 'DSCP + ECN', start: 1, length: 1, format: 'hex', color: '#e8eaf6', description: '' },
{ name: 'Total Length', start: 2, length: 2, format: 'decimal', color: '#e8f5e9', description: 'Длина этого фрагмента' },
{ name: 'Identification', start: 4, length: 2, format: 'hex', color: '#fff3e0', description: 'Одинаковый у всех фрагментов одного пакета' },
{ name: 'Flags + Frag.Offset', start: 6, length: 2, format: 'flags_fragment', color: '#fce4ec', description: 'DF — не фрагментировать, MF — есть ещё фрагменты' },
{ name: 'TTL', start: 8, length: 1, format: 'decimal', color: '#f3e5f5', description: '' },
{ name: 'Protocol', start: 9, length: 1, format: 'ip_protocol', color: '#e0f7fa', description: '' },
{ name: 'Header Checksum', start: 10, length: 2, format: 'hex', color: '#f1f8e9', description: 'Рассчитывается автоматически' },
{ name: 'Source IP', start: 12, length: 4, format: 'ip', color: '#fff8e1', description: '' },
{ name: 'Destination IP', start: 16, length: 4, format: 'ip', color: '#fbe9e7', description: '' },
],
//редактируемы только байты 6-7 (Flags + Fragment Offset)
readOnlyRanges: [
{ start: 0, end: 5 },
{ start: 8, end: 19 },
],
theory: theory['ipv4-fragmentation'],
taskTemplate: { type: 'ipv4-fragmentation' },
},
{
id: 7,
slug: 'tcp-header',
title: 'Структура TCP-заголовка',
category: 'TCP',
difficulty: 'Средний',
component: 'HexEditor',
additionalComponents: ['WiresharkView'],
wiresharkTitle: 'Структура TCP-заголовка (20 байт)',
wiresharkFields: [
{ name: 'Source Port', start: 0, length: 2, format: 'port', color: '#e3f2fd', description: 'Порт отправителя (1–65535)' },
{ name: 'Destination Port', start: 2, length: 2, format: 'port', color: '#e8f5e9', description: 'Порт получателя' },
{ name: 'Sequence Number', start: 4, length: 4, format: 'seq_ack', color: '#fff3e0', description: 'Порядковый номер первого байта данных в сегменте' },
{ name: 'Ack Number', start: 8, length: 4, format: 'seq_ack', color: '#fce4ec', description: 'Следующий ожидаемый байт (если ACK=1)' },
{ name: 'Data Offset', start: 12, length: 1, format: 'tcp_data_offset', color: '#e8eaf6', description: 'Длина заголовка в 32-битных словах (минимум 5)' },
{ name: 'Flags', start: 13, length: 1, format: 'tcp_flags', color: '#f3e5f5', description: 'CWR ECE URG ACK PSH RST SYN FIN' },
{ name: 'Window Size', start: 14, length: 2, format: 'window_size', color: '#e0f7fa', description: 'Размер окна приёма (буфера получателя)' },
{ name: 'Checksum', start: 16, length: 2, format: 'hex', color: '#f1f8e9', description: 'Контрольная сумма (с псевдозаголовком IPv4)' },
{ name: 'Urgent Pointer', start: 18, length: 2, format: 'hex', color: '#fff8e1', description: 'Актуален только при URG=1' },
],
//checksum (16-17) и Data Offset (12) — только чтение
readOnlyRanges: [
{ start: 12, end: 12 },
{ start: 16, end: 17 },
{ start: 18, end: 19 },
],
theory: theory['tcp-header'],
taskTemplate: { type: 'tcp-header' },
},
{
id: 8,
slug: 'tcp-flags',
title: 'TCP-флаги и трёхстороннее рукопожатие',
category: 'TCP',
difficulty: 'Средний',
component: 'HexEditor',
additionalComponents: ['WiresharkView'],
wiresharkTitle: 'TCP: байт флагов',
wiresharkFields: [
{ name: 'Source Port', start: 0, length: 2, format: 'port', color: '#e3f2fd', description: '' },
{ name: 'Destination Port', start: 2, length: 2, format: 'port', color: '#e8f5e9', description: '' },
{ name: 'Sequence Number', start: 4, length: 4, format: 'seq_ack', color: '#fff3e0', description: '' },
{ name: 'Ack Number', start: 8, length: 4, format: 'seq_ack', color: '#fce4ec', description: '' },
{ name: 'Data Offset', start: 12, length: 1, format: 'tcp_data_offset', color: '#e8eaf6', description: '' },
{ name: 'Flags', start: 13, length: 1, format: 'tcp_flags', color: '#f3e5f5', description: '' },
{ name: 'Window Size', start: 14, length: 2, format: 'window_size', color: '#e0f7fa', description: '' },
{ name: 'Checksum', start: 16, length: 2, format: 'hex', color: '#f1f8e9', description: 'Рассчитывается автоматически' },
{ name: 'Urgent Pointer', start: 18, length: 2, format: 'hex', color: '#fff8e1', description: '' },
],
//редактируется только байт флагов (13)
readOnlyRanges: [
{ start: 0, end: 7 }, //порты + sequence number (заданы)
{ start: 8, end: 12 }, //Ack Number и data offset
{ start: 14, end: 19 }, //window, checksum, urgent
],
theory: theory['tcp-flags'],
taskTemplate: { type: 'tcp-flags' },
},
{
id: 9,
slug: 'udp-header',
title: 'Структура UDP-заголовка',
category: 'UDP',
difficulty: 'Начинающий',
component: 'HexEditor',
additionalComponents: ['WiresharkView'],
wiresharkTitle: 'Структура UDP-заголовка (8 байт)',
wiresharkFields: [
{ name: 'Source Port', start: 0, length: 2, format: 'port', color: '#e3f2fd', description: 'Порт отправителя' },
{ name: 'Destination Port', start: 2, length: 2, format: 'port', color: '#e8f5e9', description: 'Порт получателя' },
{ name: 'Length', start: 4, length: 2, format: 'udp_length', color: '#fff3e0', description: 'Длина UDP-заголовка + данных в байтах (мин. 8)' },
{ name: 'Checksum', start: 6, length: 2, format: 'hex', color: '#f1f8e9', description: 'Контрольная сумма' },
],
//Length (4-5) и Checksum (6-7) пересчитываются автоматически
readOnlyRanges: [
{ start: 4, end: 7 },
],
theory: theory['udp-header'],
taskTemplate: { type: 'udp-ports' },
},
{
id: 10,
slug: 'http-get-request',
title: 'HTTP GET-запрос',
category: 'HTTP',
difficulty: 'Средний',
component: 'HttpEditor',
additionalComponents: [], //HttpEditor содержит разбор внутри себя
theory: theory['http-get-request'],
taskTemplate: { type: 'http-get' },
},
{
id: 11,
slug: 'http-post-request',
title: 'HTTP POST-запрос с JSON',
category: 'HTTP',
difficulty: 'Средний',
component: 'HttpEditor',
additionalComponents: [],
theory: theory['http-post-request'],
taskTemplate: { type: 'http-post' },
},
{
id: 12,
slug: 'dns-header',
title: 'Структура DNS-заголовка',
category: 'DNS',
difficulty: 'Средний',
component: 'HexEditor',
additionalComponents: ['WiresharkView'],
wiresharkTitle: 'DNS-заголовок (12 байт)',
wiresharkFields: [
{
name: 'Transaction ID',
start: 0, length: 2,
format: 'hex',
color: '#e3f2fd',
description: 'Произвольный идентификатор для сопоставления запроса и ответа',
},
{
name: 'Flags',
start: 2, length: 2,
format: 'dns_flags',
color: '#f3e5f5',
description: 'QR, Opcode, AA, TC, RD, RA, RCODE',
},
{
name: 'QDCOUNT',
start: 4, length: 2,
format: 'decimal',
color: '#e8f5e9',
description: 'Количество вопросов в секции Question',
},
{
name: 'ANCOUNT',
start: 6, length: 2,
format: 'decimal',
color: '#fff3e0',
description: 'Количество записей в секции Answer',
},
{
name: 'NSCOUNT',
start: 8, length: 2,
format: 'decimal',
color: '#fce4ec',
description: 'Количество записей в секции Authority',
},
{
name: 'ARCOUNT',
start: 10, length: 2,
format: 'decimal',
color: '#e8eaf6',
description: 'Количество записей в секции Additional',
},
],
//Flags и счётчики ANCOUNT/NSCOUNT/ARCOUNT — только чтение
//пользователь меняет Transaction ID, RD-бит в Flags и QDCOUNT
readOnlyRanges: [
{ start: 6, end: 11 }, //ANCOUNT, NSCOUNT, ARCOUNT
],
theory: theory['dns-header'],
taskTemplate: { type: 'dns-header' },
},
{
id: 13,
slug: 'dns-query',
title: 'Кодирование доменного имени в DNS',
category: 'DNS',
difficulty: 'Продвинутый',
component: 'HexEditor',
additionalComponents: ['WiresharkView'],
wiresharkTitle: 'DNS-запрос: заголовок + Question',
wiresharkFields: [
//заголовок
{ name: 'Transaction ID', start: 0, length: 2, format: 'hex', color: '#e3f2fd', description: 'Идентификатор запроса' },
{ name: 'Flags', start: 2, length: 2, format: 'dns_flags', color: '#f3e5f5', description: '' },
{ name: 'QDCOUNT', start: 4, length: 2, format: 'decimal', color: '#e8f5e9', description: 'Число вопросов' },
{ name: 'ANCOUNT', start: 6, length: 2, format: 'decimal', color: '#fff3e0', description: '' },
{ name: 'NSCOUNT', start: 8, length: 2, format: 'decimal', color: '#fce4ec', description: '' },
{ name: 'ARCOUNT', start: 10, length: 2, format: 'decimal', color: '#e8eaf6', description: '' },
//Question
{ name: 'QNAME', start: 12, length: 13, format: 'dns_qname', color: '#fff8e1', description: 'Доменное имя в DNS-кодировке (длина-метки)' },
{ name: 'QTYPE', start: 25, length: 2, format: 'dns_qtype', color: '#e0f7fa', description: 'Тип запроса: A=1, AAAA=28, MX=15, NS=2 ...' },
{ name: 'QCLASS', start: 27, length: 2, format: 'dns_qclass', color: '#f1f8e9', description: 'Класс: IN=1 (Internet)' },
],
//всё предзаполнено кроме QTYPE и QCLASS
readOnlyRanges: [
{ start: 0, end: 24 }, //заголовок + QNAME
],
theory: theory['dns-query'],
taskTemplate: { type: 'dns-query' },
},
];
+473
View File
@@ -0,0 +1,473 @@
const B = 'background:#f9fcff;padding:20px;border-radius:8px;margin:20px 0;';
const H2 = 'color:#2196F3;border-bottom:2px solid #2196F3;padding-bottom:10px;margin-top:0;';
const H3 = 'color:#1976D2;margin-top:0;';
const TH = 'background:#e3f2fd;padding:8px;border:1px solid #ccc;text-align:left;';
const TD = 'padding:6px 8px;border:1px solid #ddd;';
const TD2 = 'padding:6px 8px;border:1px solid #ddd;background:#f5f9ff;';
const TBL = 'border-collapse:collapse;width:100%;margin-top:8px;font-size:0.92em;';
const DARK = 'background:#1e1e1e;color:#d4d4d4;padding:15px;border-radius:6px;font-family:\'Courier New\',monospace;font-size:0.88em;line-height:1.9;margin:10px 0;';
const WARN = 'background:#fff8e1;padding:16px 20px;border-radius:8px;margin:20px 0;border-left:4px solid #FFC107;';
export const theory = {
'bit-manipulation': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">Бит и байт основа сетевых протоколов</h2>
<div style="${B}">
<h3 style="${H3}">Бит и байт</h3>
<p><strong>Бит</strong> минимальная единица информации, принимает значение 0 или 1.</p>
<p><strong>Байт</strong> = 8 бит. Один байт хранит число от 0 до 255. Все сетевые пакеты это последовательность байт. Зная позицию и длину поля в байтах, можно прочитать или изменить любое поле заголовка вручную.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Двоичная и шестнадцатеричная запись</h3>
<p>Один байт записывают в разных системах счисления:</p>
<div style="${DARK}">
<div>Десятичная: 184</div>
<div>Двоичная: 1011 1000</div>
<div>Шестнадцатеричная: 0xB8</div>
</div>
<p>Шестнадцатеричная запись удобна: каждая цифра (0F) точно соответствует 4 битам.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Нумерация битов</h3>
<p>Биты нумеруются справа налево: бит 7 старший (вес 128), бит 0 младший (вес 1).</p>
<table style="border-collapse:collapse;font-family:'Courier New',monospace;font-size:0.9em;margin:10px 0;">
<tr>
<td style="padding:6px 10px 6px 0;color:#1976D2;font-weight:bold;">Бит:</td>
${[7,6,5,4,3,2,1,0].map(n =>
`<td style="padding:6px 8px;text-align:center;color:#d4d4d4;background:#1e1e1e;">${n}</td>`
).join('')}
</tr>
<tr>
<td style="padding:6px 10px 6px 0;color:#1976D2;font-weight:bold;">Вес:</td>
${[128,64,32,16,8,4,2,1].map(n =>
`<td style="padding:6px 8px;text-align:center;color:#ce9178;background:#1e1e1e;">${n}</td>`
).join('')}
</tr>
</table>
<p>Чтобы получить значение байта, сложите веса всех битов равных 1.<br>
Например, биты 7, 4, 3 = 128 + 16 + 8 = <strong>152 = 0x98</strong>.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Почему это важно?</h3>
<p>В заголовках протоколов многие поля занимают несколько битов. Например, флаги TCP каждый флаг один бит. Умение работать с отдельными битами базовый навык при анализе сетевых пакетов.</p>
</div>
</div>
`,
'mac-address': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">MAC-адрес</h2>
<div style="${B}">
<h3 style="${H3}">Что такое MAC-адрес?</h3>
<p><strong>MAC-адрес</strong> (Media Access Control) идентификатор сетевого интерфейса на канальном уровне. Используется в Ethernet и Wi-Fi для адресации внутри одной сети. Длина: <strong>6 байт (48 бит)</strong>.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Структура MAC-адреса</h3>
<div style="${DARK}">
<div>00:50:56 : AB:CD:EF</div>
<div> </div>
<div>&nbsp;&nbsp;&nbsp;OUI Номер интерфейса</div>
<div>(производитель) (назначает производитель)</div>
</div>
<p>Первые 3 байта <strong>OUI</strong>, выдаются IEEE производителям. Примеры: <code>00:00:0C</code> Cisco, <code>00:02:B3</code> Intel.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Типы MAC-адресов</h3>
<table style="${TBL}">
<tr><th style="${TH}">Тип</th><th style="${TH}">Бит 0 первого байта</th><th style="${TH}">Пример</th></tr>
<tr><td style="${TD}">Unicast</td><td style="${TD}">0 (чётный байт)</td><td style="${TD}"><code>00:1A:2B:3C:4D:5E</code></td></tr>
<tr><td style="${TD2}">Multicast</td><td style="${TD2}">1 (нечётный байт)</td><td style="${TD2}"><code>01:00:5E:00:00:01</code></td></tr>
<tr><td style="${TD}">Broadcast</td><td style="${TD}">все биты = 1</td><td style="${TD}"><code>FF:FF:FF:FF:FF:FF</code></td></tr>
</table>
<p style="margin-top:12px;"><strong>Бит 1 первого байта</strong>: 0 назначен производителем, 1 локально администратором.</p>
</div>
</div>
`,
'ethernet-frame': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">Структура Ethernet-кадра</h2>
<div style="${B}">
<h3 style="${H3}">Ethernet основа проводных сетей</h3>
<p>Ethernet самая популярная технология проводных сетей. Данные передаются в виде <strong>кадров (frames)</strong>. На практике используется стандарт <strong>Ethernet II</strong>.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Структура кадра Ethernet II</h3>
<table style="border-collapse:collapse;width:100%;margin:10px 0;font-size:0.9em;">
<tr>
<th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">Dst MAC</th>
<th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">Src MAC</th>
<th style="background:#e3f2fd;padding:8px 12px;border:1px solid #ccc;text-align:center;font-weight:bold;">EtherType</th>
<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;">461500 байт</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>EtherType</strong> тип протокола: <code>0x0800</code> IPv4, <code>0x0806</code> ARP, <code>0x86DD</code> IPv6.</p>
<p><strong>FCS</strong> (Frame Check Sequence) контрольная сумма, рассчитывается автоматически.</p>
</div>
<div style="${B}">
<h3 style="${H3}">MTU</h3>
<p>Максимальный размер данных <strong>1500 байт (MTU)</strong>. Если IP-пакет больше MTU, он фрагментируется. Минимум данных 46 байт.</p>
</div>
</div>
`,
'ipv4-header': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">IPv4 протокол сетевого уровня</h2>
<div style="${B}">
<h3 style="${H3}">Что делает IP?</h3>
<p>IP обеспечивает доставку пакетов через составную сеть объединяет разные технологии канального уровня и выполняет <strong>маршрутизацию</strong>. Работает без гарантии доставки и без соединения.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Структура заголовка IPv4 (минимум 20 байт)</h3>
<div style="${DARK}">
<div>Байт 0: Version (4 бит) + IHL (4 бит)</div>
<div>Байт 1: DSCP/ECN</div>
<div>Байты 23: Total Length полная длина пакета</div>
<div>Байты 45: Identification ID для сборки фрагментов</div>
<div>Байты 67: Flags (3 бит) + Fragment Offset (13 бит)</div>
<div>Байт 8: TTL уменьшается на 1 на каждом маршрутизаторе</div>
<div>Байт 9: Protocol TCP=6, UDP=17, ICMP=1</div>
<div>Байты 1011: Header Checksum (пересчитывается автоматически)</div>
<div>Байты 1215: Source IP</div>
<div>Байты 1619: Destination IP</div>
</div>
</div>
<div style="${B}">
<h3 style="${H3}">Специальные диапазоны IPv4</h3>
<table style="${TBL}">
<tr><th style="${TH}">Диапазон</th><th style="${TH}">Назначение</th></tr>
<tr><td style="${TD}"><code>10.x.x.x</code>, <code>172.16-31.x.x</code>, <code>192.168.x.x</code></td><td style="${TD}">Частные (не маршрутизируются в интернете)</td></tr>
<tr><td style="${TD2}"><code>127.0.0.1</code></td><td style="${TD2}">Loopback (localhost)</td></tr>
<tr><td style="${TD}"><code>169.254.x.x</code></td><td style="${TD}">Link-local (без DHCP)</td></tr>
</table>
</div>
</div>
`,
'ipv4-ttl': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">TTL - время жизни пакета</h2>
<div style="${B}">
<h3 style="${H3}">Зачем нужен TTL?</h3>
<p><strong>TTL (Time To Live)</strong> - счётчик прыжков. Каждый маршрутизатор уменьшает TTL на 1. При TTL = 0 пакет отбрасывается и отправителю уходит ICMP «Time Exceeded». Защита от бесконечной петли маршрутизации.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Стандартные значения TTL</h3>
<table style="${TBL}">
<tr><th style="${TH}">ОС / устройство</th><th style="${TH}">TTL</th><th style="${TH}">Hex</th></tr>
<tr><td style="${TD}">Linux, macOS, Android</td><td style="${TD}">64</td><td style="${TD}">0x40</td></tr>
<tr><td style="${TD2}">Windows</td><td style="${TD2}">128</td><td style="${TD2}">0x80</td></tr>
<tr><td style="${TD}">Cisco IOS, сетевые устройства</td><td style="${TD}">255</td><td style="${TD}">0xFF</td></tr>
</table>
<p style="margin-top:10px;">По TTL в ответе можно примерно определить ОС удалённого хоста.</p>
</div>
<div style="${B}">
<h3 style="${H3}">traceroute и TTL</h3>
<p><code>traceroute</code> отправляет пакеты с TTL=1, 2, 3 Каждый маршрутизатор отвечает ICMP «Time Exceeded» - так строится полная цепочка узлов до цели.</p>
</div>
</div>
`,
'ipv4-fragmentation': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">Фрагментация IPv4</h2>
<div style="${B}">
<h3 style="${H3}">MTU и фрагментация</h3>
<p><strong>MTU</strong> максимальный размер данных кадра. Для Ethernet это 1500 байт. Если IP-пакет больше MTU, маршрутизатор <strong>фрагментирует</strong> его на части. Получатель собирает фрагменты по Identification, Flags и Fragment Offset.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Поля байтов 67 (Flags + Fragment Offset)</h3>
<div style="${DARK}">
<div>Бит 15: Reserved (всегда 0)</div>
<div>Бит 14: DF Don't Fragment (1 = запрет фрагментации)</div>
<div>Бит 13: MF More Fragments (1 = есть ещё фрагменты)</div>
<div>Биты 120: Fragment Offset (смещение в 8-байтовых блоках)</div>
</div>
<table style="${TBL}">
<tr><th style="${TH}">Ситуация</th><th style="${TH}">Байт 6</th><th style="${TH}">Байт 7</th></tr>
<tr><td style="${TD}">DF=1, не фрагментировать</td><td style="${TD}">0x40</td><td style="${TD}">0x00</td></tr>
<tr><td style="${TD2}">Первый фрагмент (MF=1)</td><td style="${TD2}">0x20</td><td style="${TD2}">0x00</td></tr>
<tr><td style="${TD}">Последний фрагмент, Offset=185</td><td style="${TD}">0x00</td><td style="${TD}">0xB9</td></tr>
</table>
<p style="margin-top:10px;">Fragment Offset = байты ÷ 8. Смещение 1480 байт: 1480 ÷ 8 = <strong>185 = 0xB9</strong>.</p>
</div>
</div>
`,
'tcp-header': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">TCP протокол надёжной передачи</h2>
<div style="${B}">
<h3 style="${H3}">Что делает TCP?</h3>
<p>TCP обеспечивает надёжность: <strong>подтверждения (ACK)</strong>, повторную отправку при потере, сохранение порядка. TCP нумерует не сегменты, а <strong>байты потока</strong> Sequence Number это номер первого байта в сегменте.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Структура заголовка TCP (минимум 20 байт)</h3>
<div style="${DARK}">
<div>Байты 01: Source Port</div>
<div>Байты 23: Destination Port</div>
<div>Байты 47: Sequence Number номер первого байта данных</div>
<div>Байты 811: Acknowledgment Number следующий ожидаемый байт</div>
<div>Байт 12: Data Offset (длина заголовка, мин. 5 × 4 = 20 байт)</div>
<div>Байт 13: Flags SYN, ACK, FIN, RST, PSH...</div>
<div>Байты 1415: Window Size размер буфера приёма</div>
<div>Байты 1617: Checksum (авто)</div>
<div>Байты 1819: Urgent Pointer</div>
</div>
</div>
<div style="${B}">
<h3 style="${H3}">Популярные TCP-порты</h3>
<table style="${TBL}">
<tr><th style="${TH}">Порт</th><th style="${TH}">Протокол</th><th style="${TH}">Порт</th><th style="${TH}">Протокол</th></tr>
<tr><td style="${TD}">22</td><td style="${TD}">SSH</td><td style="${TD}">443</td><td style="${TD}">HTTPS</td></tr>
<tr><td style="${TD2}">25</td><td style="${TD2}">SMTP</td><td style="${TD2}">3306</td><td style="${TD2}">MySQL</td></tr>
<tr><td style="${TD}">80</td><td style="${TD}">HTTP</td><td style="${TD}">5432</td><td style="${TD}">PostgreSQL</td></tr>
</table>
<p style="margin-top:10px;">Ephemeral-порты клиента: 4915265535.</p>
</div>
</div>
`,
'tcp-flags': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">TCP-флаги и трёхстороннее рукопожатие</h2>
<div style="${B}">
<h3 style="${H3}">Байт флагов (байт 13 заголовка)</h3>
<table style="${TBL}">
<tr><th style="${TH}">Бит</th><th style="${TH}">Флаг</th><th style="${TH}">Назначение</th></tr>
<tr><td style="${TD}">4</td><td style="${TD}"><strong>ACK</strong></td><td style="${TD}">Поле Acknowledgment Number значимо</td></tr>
<tr><td style="${TD2}">3</td><td style="${TD2}">PSH</td><td style="${TD2}">Доставить данные приложению немедленно</td></tr>
<tr><td style="${TD}">2</td><td style="${TD}"><strong>RST</strong></td><td style="${TD}">Немедленный сброс соединения</td></tr>
<tr><td style="${TD2}">1</td><td style="${TD2}"><strong>SYN</strong></td><td style="${TD2}">Установка соединения</td></tr>
<tr><td style="${TD}">0</td><td style="${TD}"><strong>FIN</strong></td><td style="${TD}">Завершение соединения</td></tr>
</table>
<p style="margin-top:10px;">Несколько флагов через OR: SYN+ACK = 0x02 | 0x10 = <strong>0x12</strong>.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Трёхстороннее рукопожатие (3-Way Handshake)</h3>
<div style="${DARK}">
<div><span style="color:#4ec9b0;">Клиент Сервер:</span> <span style="color:#ce9178;">SYN</span> Seq=X</div>
<div><span style="color:#4ec9b0;">Сервер Клиент:</span> <span style="color:#ce9178;">SYN + ACK</span> Seq=Y, Ack=X+1</div>
<div><span style="color:#4ec9b0;">Клиент Сервер:</span> <span style="color:#ce9178;">ACK</span> Seq=X+1, Ack=Y+1</div>
<div style="color:#555;"> соединение установлено</div>
</div>
</div>
</div>
`,
'udp-header': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">UDP быстрый протокол без гарантий</h2>
<div style="${B}">
<h3 style="${H3}">Что такое UDP?</h3>
<p>UDP транспортный протокол без соединения. Не гарантирует доставку и порядок. Заголовок всего <strong>8 байт</strong>, нет накладных расходов на рукопожатие. Применяется в DNS, DHCP, NTP, VoIP, онлайн-играх.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Структура заголовка UDP (8 байт)</h3>
<div style="${DARK}">
<div>Байты 01: Source Port</div>
<div>Байты 23: Destination Port</div>
<div>Байты 45: Length длина датаграммы (минимум 8)</div>
<div>Байты 67: Checksum (авто, в IPv4 необязателен)</div>
</div>
</div>
<div style="${B}">
<h3 style="${H3}">Популярные UDP-порты</h3>
<table style="${TBL}">
<tr><th style="${TH}">Порт</th><th style="${TH}">Протокол</th><th style="${TH}">Порт</th><th style="${TH}">Протокол</th></tr>
<tr><td style="${TD}">53</td><td style="${TD}">DNS</td><td style="${TD}">161</td><td style="${TD}">SNMP</td></tr>
<tr><td style="${TD2}">67/68</td><td style="${TD2}">DHCP</td><td style="${TD2}">514</td><td style="${TD2}">Syslog</td></tr>
<tr><td style="${TD}">123</td><td style="${TD}">NTP</td><td style="${TD}">5353</td><td style="${TD}">mDNS</td></tr>
</table>
</div>
</div>
`,
'http-get-request': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">HTTP протокол передачи гипертекста</h2>
<div style="${B}">
<h3 style="${H3}">Что такое HTTP?</h3>
<p>HTTP <strong>текстовый</strong> протокол прикладного уровня. Работает поверх TCP, порт 80 (HTTPS 443). Режим работы: <strong>запросответ</strong>. В отличие от Ethernet/IP не бинарный, а текстовый.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Структура HTTP-запроса</h3>
<div style="${DARK}">
<div><span style="color:#ce9178;">МЕТОД /путь HTTP/версия</span> <span style="color:#555;"> строка запроса (обязательна)</span></div>
<div><span style="color:#9cdcfe;">Заголовок1:</span> значение <span style="color:#555;"> заголовки</span></div>
<div><span style="color:#9cdcfe;">Заголовок2:</span> значение</div>
<div style="color:#555;">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; пустая строка (ОБЯЗАТЕЛЬНА!)</div>
<div><span style="color:#ce9178;">[тело запроса]</span> <span style="color:#555;"> для GET обычно отсутствует</span></div>
</div>
</div>
<div style="${B}">
<h3 style="${H3}">Основные методы</h3>
<table style="${TBL}">
<tr><th style="${TH}">Метод</th><th style="${TH}">Назначение</th><th style="${TH}">Тело</th></tr>
<tr><td style="${TD}"><strong style="color:#4caf50">GET</strong></td><td style="${TD}">Получить ресурс</td><td style="${TD}">Нет</td></tr>
<tr><td style="${TD2}"><strong style="color:#2196f3">POST</strong></td><td style="${TD2}">Создать / передать данные</td><td style="${TD2}">Да</td></tr>
<tr><td style="${TD}"><strong style="color:#ff9800">PUT</strong></td><td style="${TD}">Заменить ресурс целиком</td><td style="${TD}">Да</td></tr>
<tr><td style="${TD2}"><strong style="color:#f44336">DELETE</strong></td><td style="${TD2}">Удалить ресурс</td><td style="${TD2}">Нет</td></tr>
</table>
</div>
<div style="${WARN}">
<h3 style="color:#F57F17;margin-top:0;"> Пустая строка после заголовков обязательна!</h3>
<p style="margin:0;">Нажмите Enter <strong>дважды</strong> после последнего заголовка.</p>
</div>
</div>
`,
'http-post-request': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">HTTP POST отправка данных</h2>
<div style="${B}">
<h3 style="${H3}">POST vs GET</h3>
<p>GET получает данные. POST отправляет данные в <strong>теле запроса</strong> для создания ресурсов, передачи форм, авторизации.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Структура POST-запроса</h3>
<div style="${DARK}">
<div><span style="color:#4ec9b0;">POST</span> <span style="color:#ce9178;">/api/users</span> <span style="color:#569cd6;">HTTP/1.1</span></div>
<div><span style="color:#9cdcfe;">Host:</span> api.example.com</div>
<div><span style="color:#9cdcfe;">Content-Type:</span> application/json</div>
<div><span style="color:#9cdcfe;">Content-Length:</span> 18</div>
<div style="color:#555;">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; пустая строка</div>
<div><span style="color:#ce9178;">{"name":"Student"}</span></div>
</div>
</div>
<div style="${B}">
<h3 style="${H3}">Заголовки при наличии тела</h3>
<p><strong>Content-Type</strong> формат тела: <code>application/json</code>, <code>application/x-www-form-urlencoded</code>.</p>
<p><strong>Content-Length</strong> размер тела в <strong>байтах</strong>. Для ASCII 1 символ = 1 байт.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Коды ответа HTTP</h3>
<table style="${TBL}">
<tr><th style="${TH}">Код</th><th style="${TH}">Смысл</th></tr>
<tr><td style="${TD}">200 OK</td><td style="${TD}">Успешно</td></tr>
<tr><td style="${TD2}">201 Created</td><td style="${TD2}">Ресурс создан (ответ на POST)</td></tr>
<tr><td style="${TD}">400 Bad Request</td><td style="${TD}">Ошибка в запросе</td></tr>
<tr><td style="${TD2}">404 Not Found</td><td style="${TD2}">Ресурс не найден</td></tr>
<tr><td style="${TD}">500 Internal Server Error</td><td style="${TD}">Ошибка сервера</td></tr>
</table>
</div>
</div>
`,
'dns-header': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">DNS система доменных имён</h2>
<div style="${B}">
<h3 style="${H3}">Зачем нужен DNS?</h3>
<p>DNS переводит имена в IP: <code>www.yandex.ru</code> <code>77.88.55.66</code>. Бинарный протокол, работает поверх <strong>UDP, порт 53</strong>. При ответах >512 байт использует TCP.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Структура заголовка DNS (12 байт)</h3>
<div style="${DARK}">
<div>Байты 01: Transaction ID одинаковый в запросе и ответе</div>
<div>Байты 23: Flags</div>
<div>Байты 45: QDCOUNT число вопросов</div>
<div>Байты 67: ANCOUNT число ответов (в запросе = 0)</div>
<div>Байты 89: NSCOUNT авторитативные серверы</div>
<div>Байты 1011: ARCOUNT дополнительные записи</div>
</div>
</div>
<div style="${B}">
<h3 style="${H3}">Поле Flags (байты 23)</h3>
<table style="${TBL}">
<tr><th style="${TH}">Биты</th><th style="${TH}">Поле</th><th style="${TH}">Значение</th></tr>
<tr><td style="${TD}">15</td><td style="${TD}">QR</td><td style="${TD}">0 = запрос, 1 = ответ</td></tr>
<tr><td style="${TD2}">8</td><td style="${TD2}"><strong>RD</strong></td><td style="${TD2}">1 = просить рекурсию у сервера</td></tr>
<tr><td style="${TD}">30</td><td style="${TD}">RCODE</td><td style="${TD}">0=OK, 3=NXDOMAIN</td></tr>
</table>
<p style="margin-top:10px;"><strong>0x0100</strong> = рекурсивный запрос (RD=1). <strong>0x0000</strong> = итеративный.</p>
</div>
</div>
`,
'dns-query': `
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
<h2 style="${H2}">DNS: кодирование доменного имени</h2>
<div style="${B}">
<h3 style="${H3}">Секция Question</h3>
<p>После 12-байтного заголовка: <strong>QNAME + QTYPE + QCLASS</strong>.</p>
</div>
<div style="${B}">
<h3 style="${H3}">Кодирование QNAME</h3>
<p>Каждая метка: 1 байт длины + байты символов. В конце нулевой байт.</p>
<div style="${DARK}">
<div>example.com </div>
<div>&nbsp;&nbsp;07 65 78 61 6D 70 6C 65 (длина 7 + "example")</div>
<div>&nbsp;&nbsp;03 63 6F 6D (длина 3 + "com")</div>
<div>&nbsp;&nbsp;00 (конец имени)</div>
<div>Итого: 13 байт</div>
</div>
</div>
<div style="${B}">
<h3 style="${H3}">QTYPE и QCLASS</h3>
<table style="${TBL}">
<tr><th style="${TH}">QTYPE</th><th style="${TH}">Тип</th><th style="${TH}">Что запрашивает</th></tr>
<tr><td style="${TD}">1</td><td style="${TD}">A</td><td style="${TD}">IPv4-адрес</td></tr>
<tr><td style="${TD2}">28</td><td style="${TD2}">AAAA</td><td style="${TD2}">IPv6-адрес</td></tr>
<tr><td style="${TD}">15</td><td style="${TD}">MX</td><td style="${TD}">Почтовый сервер</td></tr>
<tr><td style="${TD2}">2</td><td style="${TD2}">NS</td><td style="${TD2}">Авторитативный DNS-сервер</td></tr>
<tr><td style="${TD}">5</td><td style="${TD}">CNAME</td><td style="${TD}">Псевдоним</td></tr>
<tr><td style="${TD2}">16</td><td style="${TD2}">TXT</td><td style="${TD2}">Текстовая запись</td></tr>
</table>
<p style="margin-top:10px;"><strong>QCLASS = 1</strong> = IN (Internet) единственный используемый класс.</p>
</div>
</div>
`,
};
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+51
View 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
View 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
View 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,
});
}
+23
View File
@@ -0,0 +1,23 @@
import { updateEthernetBuffer } from './protocols/ethernet.js';
import { updateIPv4Buffer } from './protocols/ipv4.js';
import { updateTCPBuffer } from './protocols/tcp.js';
import { updateUDPBuffer } from './protocols/udp.js';
export function processBuffer(buffer, lessonId) {
switch (lessonId) {
case 3: // Ethernet frame
return updateEthernetBuffer(buffer);
case 4: // IPv4 header structure
case 5: // IPv4 TTL
case 6: // IPv4 fragmentation
return updateIPv4Buffer(buffer);
case 7: // TCP header structure
case 8: // TCP flags
return updateTCPBuffer(buffer);
case 9: // UDP header
return updateUDPBuffer(buffer);
// id 10 = HTTP GET, id 11 = HTTP POST — текстовые, не трогаем буфер
default:
return buffer;
}
}
+65
View File
@@ -0,0 +1,65 @@
export function formatDNSFlags(bytes) {
if (bytes.length < 2) return 'N/A';
const raw = (bytes[0] << 8) | bytes[1];
const qr = (raw >> 15) & 1;
const opcode = (raw >> 11) & 0xf;
const aa = (raw >> 10) & 1;
const tc = (raw >> 9) & 1;
const rd = (raw >> 8) & 1;
const ra = (raw >> 7) & 1;
const rcode = raw & 0xf;
const opcodeNames = { 0: 'QUERY', 1: 'IQUERY', 2: 'STATUS' };
const rcodeNames = { 0: 'NOERROR', 1: 'FORMERR', 2: 'SERVFAIL', 3: 'NXDOMAIN', 5: 'REFUSED' };
const parts = [
`QR=${qr} (${qr === 0 ? 'Query' : 'Response'})`,
`Opcode=${opcodeNames[opcode] ?? opcode}`,
`RD=${rd}`,
`RA=${ra}`,
];
if (aa) parts.push('AA=1');
if (tc) parts.push('TC=1');
if (rcode) parts.push(`RCODE=${rcodeNames[rcode] ?? rcode}`);
return parts.join(', ');
}
export function formatDNSQType(bytes) {
if (bytes.length < 2) return 'N/A';
const val = (bytes[0] << 8) | bytes[1];
const types = {
1: 'A (IPv4-адрес)',
2: 'NS (Name Server)',
5: 'CNAME (Canonical Name)',
12: 'PTR (Pointer)',
15: 'MX (Mail Exchange)',
16: 'TXT (Text)',
28: 'AAAA (IPv6-адрес)',
255: 'ANY',
};
return types[val] ?? `Unknown (${val})`;
}
export function formatDNSQClass(bytes) {
if (bytes.length < 2) return 'N/A';
const val = (bytes[0] << 8) | bytes[1];
return val === 1 ? 'IN (Internet)' : `Unknown (${val})`;
}
export function formatDNSQName(bytes) {
if (!bytes || bytes.length === 0) return 'N/A';
const labels = [];
let i = 0;
while (i < bytes.length) {
const len = bytes[i];
if (len === 0) break;
//защита от выхода за границы
if (i + 1 + len > bytes.length) return labels.join('.') + ' (truncated)';
const label = String.fromCharCode(...bytes.slice(i + 1, i + 1 + len));
labels.push(label);
i += 1 + len;
}
return labels.length ? labels.join('.') : '(empty)';
}
+30
View File
@@ -0,0 +1,30 @@
//функции для работы с Ethernet
function calculateFCS(headerBuffer) {
//упрощенная имитация CRC32
let sum = 0;
for (let i = 0; i < headerBuffer.length; i++) {
sum = (sum + headerBuffer[i]) & 0xFFFFFFFF;
}
for (let i = 0; i < 46; i++) {
sum = (sum + 0x00) & 0xFFFFFFFF;
}
return [
(sum >> 24) & 0xFF,
(sum >> 16) & 0xFF,
(sum >> 8) & 0xFF,
sum & 0xFF
];
}
export function updateEthernetBuffer(buffer) {
const updatedBuffer = new Uint8Array(buffer);
const headerBuffer = updatedBuffer.slice(0, 14);
const fcs = calculateFCS(headerBuffer);
fcs.forEach((byte, i) => {
updatedBuffer[60 + i] = byte;
});
return updatedBuffer;
}
+63
View File
@@ -0,0 +1,63 @@
function calculateIPv4Checksum(buffer) {
const ihl = (buffer[0] & 0x0f) * 4;
let sum = 0;
for (let i = 0; i < ihl; i += 2) {
if (i === 10) continue;
sum += (buffer[i] << 8) | buffer[i + 1];
}
while (sum >> 16) {
sum = (sum & 0xffff) + (sum >> 16);
}
return (~sum) & 0xffff;
}
export function updateIPv4Buffer(buffer) {
const updated = new Uint8Array(buffer);
updated[10] = 0x00;
updated[11] = 0x00;
const checksum = calculateIPv4Checksum(updated);
updated[10] = (checksum >> 8) & 0xff;
updated[11] = checksum & 0xff;
return updated;
}
export function formatIPv4Address(bytes) {
if (bytes.length < 4) return 'N/A';
return `${bytes[0]}.${bytes[1]}.${bytes[2]}.${bytes[3]}`;
}
export function formatIPv4Protocol(proto) {
const protocols = {
1: 'ICMP',
6: 'TCP',
17: 'UDP',
47: 'GRE',
50: 'ESP',
51: 'AH',
89: 'OSPF',
132: 'SCTP',
};
const name = protocols[proto] ?? 'Unknown';
return `${name} (${proto} / 0x${proto.toString(16).padStart(2, '0').toUpperCase()})`;
}
export function formatVersionIHL(byte) {
const version = (byte >> 4) & 0x0f;
const ihl = (byte & 0x0f) * 4;
return `IPv${version}, Header Length: ${ihl} bytes`;
}
export function formatFlagsFragment(bytes) {
if (bytes.length < 2) return 'N/A';
const raw = (bytes[0] << 8) | bytes[1];
const df = (raw >> 14) & 1;
const mf = (raw >> 13) & 1;
const offset = raw & 0x1fff;
return `DF=${df}, MF=${mf}, Offset=${offset}`;
}
+80
View File
@@ -0,0 +1,80 @@
//контрольная сумма TCP считается с псевдозаголовком IPv4 (src IP, dst IP,
//protocol=6, длина TCP-сегмента). В уроках IP-адреса фиксированы:
const LESSON_SRC_IP = [192, 168, 1, 1];
const LESSON_DST_IP = [192, 168, 1, 2];
export function calculateTCPChecksum(tcpBuffer, srcIP = LESSON_SRC_IP, dstIP = LESSON_DST_IP) {
const tcpLen = tcpBuffer.length;
//псевдозаголовок: src(4) + dst(4) + zero(1) + proto(1) + tcpLen(2) = 12 байт
const pseudo = new Uint8Array(12 + tcpLen);
pseudo.set(srcIP, 0);
pseudo.set(dstIP, 4);
pseudo[8] = 0x00;
pseudo[9] = 0x06; //протокол TCP
pseudo[10] = (tcpLen >> 8) & 0xff;
pseudo[11] = tcpLen & 0xff;
pseudo.set(tcpBuffer, 12);
pseudo[28] = 0x00;
pseudo[29] = 0x00;
let sum = 0;
for (let i = 0; i < pseudo.length - 1; i += 2) {
sum += (pseudo[i] << 8) | pseudo[i + 1];
}
//дополняем нечётный байт
if (pseudo.length % 2 !== 0) {
sum += pseudo[pseudo.length - 1] << 8;
}
while (sum >> 16) {
sum = (sum & 0xffff) + (sum >> 16);
}
return (~sum) & 0xffff;
}
export function updateTCPBuffer(buffer) {
const updated = new Uint8Array(buffer);
updated[16] = 0x00;
updated[17] = 0x00;
const cs = calculateTCPChecksum(updated);
updated[16] = (cs >> 8) & 0xff;
updated[17] = cs & 0xff;
return updated;
}
export function formatPort(bytes) {
if (bytes.length < 2) return 'N/A';
const port = (bytes[0] << 8) | bytes[1];
const wellKnown = {
20: 'FTP-data', 21: 'FTP', 22: 'SSH', 23: 'Telnet',
25: 'SMTP', 53: 'DNS', 80: 'HTTP', 110: 'POP3',
143: 'IMAP', 443: 'HTTPS', 3306: 'MySQL', 5432: 'PostgreSQL',
6379: 'Redis', 8080: 'HTTP-alt',
};
const name = wellKnown[port];
return name ? `${port} (${name})` : String(port);
}
export function formatSeqAck(bytes) {
if (bytes.length < 4) return 'N/A';
const val = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0;
return String(val);
}
export function formatDataOffset(byte) {
const offset = (byte >> 4) & 0x0f;
return `${offset} (${offset * 4} bytes)`;
}
export function formatTCPFlags(byte) {
const names = ['CWR', 'ECE', 'URG', 'ACK', 'PSH', 'RST', 'SYN', 'FIN'];
const active = names.filter((_, i) => byte & (1 << (7 - i)));
return active.length ? active.join(' | ') : 'none';
}
export function formatWindowSize(bytes) {
if (bytes.length < 2) return 'N/A';
const val = (bytes[0] << 8) | bytes[1];
return `${val} bytes`;
}
+56
View File
@@ -0,0 +1,56 @@
const LESSON_SRC_IP = [192, 168, 1, 1];
const LESSON_DST_IP = [192, 168, 1, 2];
export function calculateUDPChecksum(udpBuffer, srcIP = LESSON_SRC_IP, dstIP = LESSON_DST_IP) {
const udpLen = udpBuffer.length;
const pseudo = new Uint8Array(12 + udpLen);
pseudo.set(srcIP, 0);
pseudo.set(dstIP, 4);
pseudo[8] = 0x00;
pseudo[9] = 0x11; //протокол UDP = 17
pseudo[10] = (udpLen >> 8) & 0xff;
pseudo[11] = udpLen & 0xff;
pseudo.set(udpBuffer, 12);
pseudo[18] = 0x00;
pseudo[19] = 0x00;
let sum = 0;
for (let i = 0; i < pseudo.length - 1; i += 2) {
sum += (pseudo[i] << 8) | pseudo[i + 1];
}
if (pseudo.length % 2 !== 0) {
sum += pseudo[pseudo.length - 1] << 8;
}
while (sum >> 16) {
sum = (sum & 0xffff) + (sum >> 16);
}
const result = (~sum) & 0xffff;
//по RFC 768: если вычисленная сумма = 0, передаётся 0xFFFF
return result === 0 ? 0xffff : result;
}
export function updateUDPBuffer(buffer) {
const updated = new Uint8Array(buffer);
//Length = заголовок (8) + данные
const len = buffer.length;
updated[4] = (len >> 8) & 0xff;
updated[5] = len & 0xff;
//Checksum
updated[6] = 0x00;
updated[7] = 0x00;
const cs = calculateUDPChecksum(updated);
updated[6] = (cs >> 8) & 0xff;
updated[7] = cs & 0xff;
return updated;
}
export function formatUDPLength(bytes) {
if (bytes.length < 2) return 'N/A';
const val = (bytes[0] << 8) | bytes[1];
return `${val} bytes (заголовок 8 + данные ${val - 8})`;
}
+115
View File
@@ -0,0 +1,115 @@
//прогресс хранится в БД если пользователь авторизован,
//иначе в localStorage (для гостей)
const TASKS_REQUIRED = 3;
export const progressStorage = {
_getAll() {
if (typeof window === 'undefined') return {};
try {
return JSON.parse(localStorage.getItem('networking-progress') || '{}');
} catch {
return {};
}
},
_save(data) {
if (typeof window === 'undefined') return;
localStorage.setItem('networking-progress', JSON.stringify(data));
},
_getLessonData(lessonId) {
const all = this._getAll();
const raw = all[String(lessonId)];
if (raw === undefined || raw === null) return { completed: false, tasksCompleted: 0 };
if (typeof raw === 'boolean') return { completed: raw, tasksCompleted: raw ? TASKS_REQUIRED : 0 };
return { completed: !!raw.completed, tasksCompleted: raw.tasksCompleted ?? 0 };
},
isCompleted(lessonId) {
return this._getLessonData(lessonId).completed;
},
getTasksCompleted(lessonId) {
return this._getLessonData(lessonId).tasksCompleted;
},
//сохраняет новое значение счётчика; автоматически выставляет completed
saveTasksCompleted(lessonId, tasksCompleted, tasksRequired = TASKS_REQUIRED) {
const all = this._getAll();
const completed = tasksCompleted >= tasksRequired;
all[String(lessonId)] = { completed, tasksCompleted };
this._save(all);
this._syncToDB(lessonId, tasksCompleted, completed);
return completed;
},
//используется на главной странице (список уроков)
getProgress() {
const all = this._getAll();
const result = {};
for (const [id, val] of Object.entries(all)) {
if (typeof val === 'boolean') result[id] = val;
else result[id] = !!val?.completed;
}
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');
}
},
};
+365
View File
@@ -0,0 +1,365 @@
//генерирует конкретные задачи из шаблона урока
//AI не участвует — числа выбираются случайно в заданном диапазоне
function rnd(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
function randomUnicastMAC() {
const m = Array.from({ length: 6 }, () => rnd(0, 255));
m[0] = m[0] & 0xFE; // бит 0 = 0 (unicast)
if (m[0] === 0) m[0] = 0x02;
return m;
}
function randomMulticastMAC() {
const m = Array.from({ length: 6 }, () => rnd(0, 255));
m[0] = (m[0] | 0x01) & 0xFD;
if (m[0] === 0xFF) m[0] = 0x01;
return m;
}
function macStr(m) { return m.map(b => b.toString(16).padStart(2,'0').toUpperCase()).join(':'); }
function randomPublicIP() {
let ip;
do {
ip = [rnd(1,223), rnd(0,255), rnd(0,255), rnd(1,254)];
} while (
ip[0]===0 || ip[0]===10 || ip[0]===127 ||
(ip[0]===172 && ip[1]>=16 && ip[1]<=31) ||
(ip[0]===192 && ip[1]===168) ||
(ip[0]===169 && ip[1]===254) ||
(ip[0]===100 && ip[1]>=64 && ip[1]<=127) ||
ip[0]>=224
);
return ip;
}
function randomPrivateIP() {
const t = rnd(0,2);
if (t===0) return [10, rnd(0,255), rnd(0,255), rnd(1,254)];
if (t===1) return [172, rnd(16,31), rnd(0,255), rnd(1,254)];
return [192, 168, rnd(0,255), rnd(1,254)];
}
function randomEphemeralPort() { return rnd(49152, 65535); }
function randomHostname() {
const subs = ['api','cdn','app','mail','news','shop','auth','www','dev','data','files'];
const names = ['example','service','platform','network','cloud','tech','store','media','hub'];
const tlds = ['com','net','io','org','ru'];
return `${pick(subs)}.${pick(names)}.${pick(tlds)}`;
}
function randomPath() {
const a = ['users','products','orders','articles','posts','files','events','reports','tasks'];
const b = ['list','search','latest','popular','archive','create','delete'];
return Math.random()>0.5 ? '/'+pick(a) : '/'+pick(a)+'/'+pick(b);
}
function randomJSONBody() {
const bodies = [
{ name: pick(['Alice','Bob','Carol','Dave','Eve','Max']) },
{ user: pick(['admin','guest','student','teacher']), active: true },
{ title: pick(['Hello','Update','Report','Notice','Draft']) },
{ id: rnd(1,9999), status: pick(['active','pending','done']) },
{ email: `user${rnd(1,99)}@example.com` },
{ count: rnd(1,100), page: rnd(1,10) },
];
return JSON.stringify(pick(bodies));
}
export function generateTask(taskTemplate) {
switch (taskTemplate.type) {
case 'bit-set': {
const target = rnd(1, 255);
const hex = '0x' + target.toString(16).toUpperCase().padStart(2,'0');
return {
objective: `Установите биты так, чтобы получить число ${target} (${hex})`,
initialBuffer: new Uint8Array([0]),
validate: buf => buf[0] === target,
aiContext: { type:'bit-set', target, hex, binary: target.toString(2).padStart(8,'0'),
description: `Выставить биты байта = ${target} (${hex})` },
};
}
case 'mac-set': {
const v = rnd(0,2);
if (v===0) {
const m = randomUnicastMAC(), s = macStr(m);
return { objective:`Установите unicast MAC-адрес: ${s}`,
initialBuffer: new Uint8Array(6),
validate: buf => m.every((b,i) => buf[i]===b),
aiContext:{ type:'mac-set', mac:s, description:`unicast MAC ${s}` }};
}
if (v===1) return { objective:'Установите broadcast MAC-адрес',
initialBuffer: new Uint8Array(6),
validate: buf => Array.from(buf.slice(0,6)).every(b=>b===0xFF),
aiContext:{ type:'mac-set', mac:'FF:FF:FF:FF:FF:FF', description:'broadcast' }};
const m = randomMulticastMAC(), s = macStr(m);
return { objective:`Установите multicast MAC-адрес: ${s}`,
initialBuffer: new Uint8Array(6),
validate: buf => m.every((b,i) => buf[i]===b),
aiContext:{ type:'mac-set', mac:s, description:`multicast MAC ${s}` }};
}
case 'ethernet-frame': {
const etTypes = [
{ et:[0x08,0x00], name:'IPv4' },
{ et:[0x08,0x06], name:'ARP' },
{ et:[0x86,0xDD], name:'IPv6' },
];
const chosen = pick(etTypes);
const dv = rnd(0,2);
let dst, dstDesc;
if (dv===0) { dst=[0xFF,0xFF,0xFF,0xFF,0xFF,0xFF]; dstDesc='broadcast'; }
else if (dv===1) { dst=randomMulticastMAC(); dstDesc=macStr(dst)+' (multicast)'; }
else { dst=randomUnicastMAC(); dstDesc=macStr(dst)+' (unicast)'; }
const src=randomUnicastMAC();
const etHex='0x'+chosen.et.map(b=>b.toString(16).padStart(2,'0').toUpperCase()).join('');
return {
objective:`Соберите Ethernet-кадр: Dst MAC=${dstDesc}, Src MAC=${macStr(src)}, EtherType=${chosen.name}`,
initialBuffer: new Uint8Array(14+46+4),
validate: buf => buf.length>=14 && dst.every((b,i)=>buf[i]===b)
&& src.every((b,i)=>buf[6+i]===b) && buf[12]===chosen.et[0] && buf[13]===chosen.et[1],
aiContext:{ type:'ethernet-frame', dstMAC:macStr(dst), srcMAC:macStr(src),
etherType:etHex, protoName:chosen.name,
description:`dst=${macStr(dst)} src=${macStr(src)} et=${etHex}` },
};
}
case 'ipv4-addresses': {
const src=randomPrivateIP(), dst=randomPublicIP();
const ttl=pick([64,128,255]);
const p=pick([{val:6,name:'TCP'},{val:17,name:'UDP'},{val:1,name:'ICMP'}]);
return {
objective:`Соберите IPv4 заголовок: Src=${src.join('.')}, Dst=${dst.join('.')}, Protocol=${p.name}, TTL=${ttl}`,
initialBuffer: new Uint8Array([0x45,0x00,0x00,0x14,0,0,0x40,0,0,0,0,0,0,0,0,0,0,0,0,0]),
validate: buf => buf.length>=20 && buf[8]===ttl && buf[9]===p.val
&& src.every((b,i)=>buf[12+i]===b) && dst.every((b,i)=>buf[16+i]===b),
aiContext:{ type:'ipv4-addresses', srcIP:src.join('.'), dstIP:dst.join('.'),
ttl, protocol:p.val, protoName:p.name,
description:`src=${src.join('.')} dst=${dst.join('.')} ttl=${ttl} proto=${p.name}` },
};
}
case 'ipv4-ttl': {
const ttl = Math.random()>0.4 ? pick([64,128,255,32]) : rnd(1,254);
const label={64:' (Linux/macOS)',128:' (Windows)',255:' (сетевые устройства)'}[ttl]??'';
const src=randomPrivateIP(), dst=randomPublicIP();
return {
objective:`Установите TTL = ${ttl}${label}`,
initialBuffer: new Uint8Array([0x45,0x00,0x00,0x14,0xAB,0xCD,0x40,0x00,
0x00,0x06,0x00,0x00,...src,...dst]),
validate: buf => buf[8]===ttl,
aiContext:{ type:'ipv4-ttl', ttl, hex:'0x'+ttl.toString(16).toUpperCase().padStart(2,'0'),
description:`TTL=${ttl} байт 8` },
};
}
case 'ipv4-fragmentation': {
const scenarios=[
{b:[0x20,0x00], desc:'первый фрагмент: MF=1, DF=0, Offset=0'},
{b:[0x40,0x00], desc:"Don't Fragment (DF=1): фрагментация запрещена, MF=0, Offset=0"},
{b:[0x00,0xB9], desc:'последний фрагмент: MF=0, DF=0, Offset=185 (1480 байт ÷ 8)'},
{b:[0x20,0xB9], desc:'промежуточный фрагмент: MF=1, DF=0, Offset=185'},
{b:[0x00,0x2E], desc:'фрагмент с Offset=46 (368 байт ÷ 8): MF=0, DF=0'},
];
const s=pick(scenarios);
const src=randomPrivateIP(), dst=randomPublicIP();
return {
objective: 'Установите ' + s.desc,
initialBuffer:new Uint8Array([0x45,0x00,0x00,0x14,rnd(0,255),rnd(0,255),
0x40,0x00,0x40,0x11,0x00,0x00,...src,...dst]),
validate:buf=>buf[6]===s.b[0]&&buf[7]===s.b[1],
aiContext:{type:'ipv4-fragmentation',b6:s.b[0],b7:s.b[1],description:s.desc},
};
}
case 'tcp-header': {
const svcs=[{p:80,n:'HTTP'},{p:443,n:'HTTPS'},{p:22,n:'SSH'},
{p:25,n:'SMTP'},{p:3306,n:'MySQL'},{p:5432,n:'PostgreSQL'}];
const svc=pick(svcs), sp=randomEphemeralPort(), seq=rnd(0,0xFFFFFF);
const win=pick([8192,16384,32768,65535]);
return {
objective:`Соберите TCP SYN к ${svc.n}: Src Port=${sp}, Dst Port=${svc.p}, Seq=${seq}, SYN (0x02), Window=${win}`,
initialBuffer:new Uint8Array(20).fill(0).map((_,i)=>i===12?0x50:0),
validate:buf=>buf.length>=20
&&((buf[0]<<8)|buf[1])===sp&&((buf[2]<<8)|buf[3])===svc.p
&&(((buf[4]<<24)|(buf[5]<<16)|(buf[6]<<8)|buf[7])>>>0)===seq
&&buf[13]===0x02&&((buf[14]<<8)|buf[15])===win,
aiContext:{type:'tcp-header',srcPort:sp,dstPort:svc.p,seq,flags:0x02,window:win,service:svc.n,
description:`TCP SYN sp=${sp} dp=${svc.p} seq=${seq}`},
};
}
case 'tcp-flags': {
const combos=[
{f:0x02,n:'SYN', d:'инициация соединения (1-й шаг handshake)'},
{f:0x10,n:'ACK', d:'подтверждение получения данных'},
{f:0x12,n:'SYN+ACK', d:'ответ сервера (2-й шаг handshake)'},
{f:0x01,n:'FIN', d:'инициация завершения соединения'},
{f:0x11,n:'FIN+ACK', d:'завершение с подтверждением'},
{f:0x04,n:'RST', d:'немедленный сброс соединения'},
{f:0x18,n:'PSH+ACK', d:'передача данных без буферизации'},
];
const c=pick(combos), sp=randomEphemeralPort(), dp=pick([80,443,22,25,3306]);
const seq=rnd(0,0xFFFFFF);
return {
objective:`Установите TCP-флаги: ${c.n}${c.d}. Байт флагов = 0x${c.f.toString(16).toUpperCase().padStart(2,'0')}`,
initialBuffer:new Uint8Array([(sp>>8)&0xFF,sp&0xFF,(dp>>8)&0xFF,dp&0xFF,
(seq>>24)&0xFF,(seq>>16)&0xFF,(seq>>8)&0xFF,seq&0xFF,0,0,0,0,
0x50,0x00,0xFF,0xFF,0,0,0,0]),
validate:buf=>buf[13]===c.f,
aiContext:{type:'tcp-flags',flagName:c.n,
flagHex:'0x'+c.f.toString(16).toUpperCase().padStart(2,'0'),
description:`TCP флаги ${c.n} байт 13`},
};
}
case 'udp-ports': {
const svcs=[{d:53,n:'DNS'},{d:123,n:'NTP'},{d:67,n:'DHCP'},
{d:161,n:'SNMP'},{d:514,n:'Syslog'},{d:5353,n:'mDNS'},
{d:1194,n:'OpenVPN'},{d:4500,n:'IPSec NAT-T'}];
const svc=pick(svcs), sp=randomEphemeralPort();
return {
objective:`Соберите UDP для ${svc.n}: Src Port=${sp}, Dst Port=${svc.d}`,
initialBuffer:new Uint8Array(8),
validate:buf=>((buf[0]<<8)|buf[1])===sp&&((buf[2]<<8)|buf[3])===svc.d,
aiContext:{type:'udp-ports',srcPort:sp,dstPort:svc.d,service:svc.n,
description:`UDP src=${sp} dst=${svc.d} (${svc.n})`},
};
}
case 'http-get': {
const host=randomHostname(), path=randomPath();
const accept=pick(['application/json','text/html','application/xml','text/plain']);
return {
objective:`Отправьте GET-запрос: ресурс ${path} с сервера ${host}, Accept: ${accept}`,
initialBuffer:new TextEncoder().encode('GET / HTTP/1.1\n'),
validate:buf=>{
const txt=new TextDecoder().decode(buf).replace(/\r\n/g,'\n');
const lines=txt.split('\n');
const rl=lines[0]?.match(/^(\S+)\s+(\S+)\s+(\S+)$/);
if(!rl||rl[1]!=='GET'||rl[2]!==path||rl[3]!=='HTTP/1.1') return false;
const h={};
for(let i=1;i<lines.length;i++){
if(!lines[i].trim()) break;
const c=lines[i].indexOf(':');
if(c>0) h[lines[i].slice(0,c).trim().toLowerCase()]=lines[i].slice(c+1).trim();
}
return h['host']===host&&(h['accept']??'').includes(accept)&&txt.includes('\n\n');
},
aiContext:{type:'http-get',host,path,accept,description:`GET ${path} от ${host}`},
};
}
case 'http-post': {
const host=randomHostname(), path=randomPath();
const body=randomJSONBody(), bl=new TextEncoder().encode(body).length;
return {
objective:`Отправьте POST на ${host}${path}: тело ${body}, Content-Type: application/json`,
initialBuffer:new TextEncoder().encode('POST / HTTP/1.1\n'),
validate:buf=>{
const txt=new TextDecoder().decode(buf).replace(/\r\n/g,'\n');
const lines=txt.split('\n');
const rl=lines[0]?.match(/^(\S+)\s+(\S+)\s+(\S+)$/);
if(!rl||rl[1]!=='POST'||rl[2]!==path||rl[3]!=='HTTP/1.1') return false;
const h={};let bs=lines.length;
for(let i=1;i<lines.length;i++){
if(!lines[i].trim()){bs=i+1;break;}
const c=lines[i].indexOf(':');
if(c>0) h[lines[i].slice(0,c).trim().toLowerCase()]=lines[i].slice(c+1).trim();
}
if(h['host']!==host) return false;
if(!(h['content-type']??'').includes('application/json')) return false;
const bodyText=lines.slice(bs).join('\n').trim();
try{ if(JSON.stringify(JSON.parse(bodyText))!==JSON.stringify(JSON.parse(body))) return false; }
catch{ return false; }
return parseInt(h['content-length']??'',10)===new TextEncoder().encode(bodyText).length
&&txt.includes('\n\n');
},
aiContext:{type:'http-post',host,path,body,bodyLength:bl,
description:`POST ${path}${host}`},
};
}
case 'dns-header': {
const txId=rnd(1,0xFFFE);
const wantRD=Math.random()>0.4;
const flags=wantRD?0x0100:0x0000;
const fdesc=wantRD?'рекурсивный запрос (RD=1)':'итеративный запрос (RD=0)';
return {
objective:`Отправьте DNS-запрос: ID=${txId}, ${fdesc}, QDCOUNT=1`,
initialBuffer:new Uint8Array(12),
validate:buf=>buf.length>=6
&&((buf[0]<<8)|buf[1])===txId
&&((buf[2]<<8)|buf[3])===flags
&&((buf[4]<<8)|buf[5])===1,
aiContext:{type:'dns-header',txId,flags,flagsDesc:fdesc,
txH:(txId>>8)&0xFF,txL:txId&0xFF,
description:`DNS ID=${txId} Flags=0x${flags.toString(16).padStart(4,'0')}`},
};
}
case 'dns-query': {
const qtypes=[
{val:1, n:'A', d:'IPv4-адрес'},
{val:28,n:'AAAA', d:'IPv6-адрес'},
{val:15,n:'MX', d:'почтовый сервер'},
{val:2, n:'NS', d:'DNS-сервер зоны'},
{val:5, n:'CNAME', d:'псевдоним'},
{val:16,n:'TXT', d:'текстовая запись'},
];
const qt=pick(qtypes), txId=rnd(1,0xFFFE);
return {
objective:`Отправьте запрос на example.com: QTYPE=${qt.n}, QCLASS=IN(1)`,
initialBuffer:new Uint8Array([
(txId>>8)&0xFF,txId&0xFF,0x01,0x00,0x00,0x01,0,0,0,0,0,0,
0x07,0x65,0x78,0x61,0x6D,0x70,0x6C,0x65,
0x03,0x63,0x6F,0x6D,0x00,
0x00,0x00,0x00,0x00,
]),
validate:buf=>buf.length>=29&&((buf[25]<<8)|buf[26])===qt.val&&((buf[27]<<8)|buf[28])===1,
aiContext:{type:'dns-query',qtypeName:qt.n,qtypeVal:qt.val,
description:`QTYPE=${qt.n}(${qt.val}), QCLASS=IN`},
};
}
default:
throw new Error(`Unknown task type: ${template.type}`);
}
}
export function buildCurrentState(buf, ctx) {
if (!buf||!ctx) return {};
switch(ctx.type){
case 'bit-set':
return{value:buf[0],binary:buf[0].toString(2).padStart(8,'0'),
hex:'0x'+buf[0].toString(16).toUpperCase().padStart(2,'0')};
case 'mac-set': case 'ethernet-frame':
return{dst:Array.from(buf.slice(0,6)).map(b=>b.toString(16).padStart(2,'0').toUpperCase()).join(':'),
src:Array.from(buf.slice(6,12)).map(b=>b.toString(16).padStart(2,'0').toUpperCase()).join(':')};
case 'ipv4-addresses': case 'ipv4-fragmentation':
return{srcIP:Array.from(buf.slice(12,16)).join('.'),dstIP:Array.from(buf.slice(16,20)).join('.'),
ttl:buf[8],proto:buf[9],
b6:'0x'+(buf[6]??0).toString(16).padStart(2,'0').toUpperCase(),
b7:'0x'+(buf[7]??0).toString(16).padStart(2,'0').toUpperCase()};
case 'ipv4-ttl':
return{ttl:buf[8],hex:'0x'+buf[8].toString(16).toUpperCase().padStart(2,'0')};
case 'tcp-header':
return{srcPort:(buf[0]<<8)|buf[1],dstPort:(buf[2]<<8)|buf[3],
seq:((buf[4]<<24)|(buf[5]<<16)|(buf[6]<<8)|buf[7])>>>0,
flags:'0x'+buf[13].toString(16).padStart(2,'0').toUpperCase(),
window:(buf[14]<<8)|buf[15]};
case 'tcp-flags':
return{flags:buf[13],hex:'0x'+buf[13].toString(16).toUpperCase().padStart(2,'0')};
case 'udp-ports':
return{srcPort:(buf[0]<<8)|buf[1],dstPort:(buf[2]<<8)|buf[3]};
case 'http-get': case 'http-post':
return{text:new TextDecoder().decode(buf).slice(0,300)};
case 'dns-header':
return{id:(buf[0]<<8)|buf[1],
flags:'0x'+((buf[2]<<8)|buf[3]).toString(16).padStart(4,'0').toUpperCase(),
qdcount:(buf[4]<<8)|buf[5]};
case 'dns-query':
return{qtype:(buf[25]<<8)|buf[26],qclass:(buf[27]<<8)|buf[28]};
default: return{};
}
}
+11
View File
@@ -0,0 +1,11 @@
<script>
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children?.()}
+3
View File
@@ -0,0 +1,3 @@
export async function load({ locals }) {
return { user: locals.user };
}
+609
View File
@@ -0,0 +1,609 @@
<script>
import { lessons } from '$lib/data/lessons';
import { progressStorage } from '$lib/utils/storage';
import { onMount } from 'svelte';
export let data; //получаем user
$: user = data?.user ?? null;
let progress = {};
let progressData = {}; //детальный прогресс для плашек { tasksCompleted, completed }
let showProfileMenu = false;
let searchQuery = '';
let activeCategory = 'Все';
let activeDifficulty = 'Все';
onMount(async () => {
if (user) {
await progressStorage.transferToDB(); //localStorage -> БД при первом входе
await progressStorage.loadFromDB(); //БД -> localStorage
}
progressData = progressStorage._getAll();
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))];
$: difficulties = ['Все', ...new Set(lessons.map(l => l.difficulty))];
//фильтрация
$: filtered = lessons.filter(l => {
const q = searchQuery.trim().toLowerCase();
const matchSearch = !q
|| l.title.toLowerCase().includes(q)
|| l.category.toLowerCase().includes(q)
|| l.difficulty.toLowerCase().includes(q);
const matchCat = activeCategory === 'Все' || l.category === activeCategory;
const matchDiff = activeDifficulty === 'Все' || l.difficulty === activeDifficulty;
return matchSearch && matchCat && matchDiff;
});
function clearFilters() {
searchQuery = '';
activeCategory = 'Все';
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) {
return { 'Начинающий': '#4caf50', 'Средний': '#ff9800', 'Продвинутый': '#f44336' }[d] ?? '#2196f3';
}
</script>
<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">
<h1>Обучение сетевым протоколам</h1>
<p class="subtitle">Изучайте структуру сетевых пакетов через интерактивные задания</p>
{#if completedCount > 0}
<div class="progress-summary">
✅ Пройдено уроков: <strong>{completedCount} / {lessons.length}</strong>
</div>
{/if}
</header>
<!--поиск и фильтры-->
<div class="controls">
<!--поиск-->
<div class="search-wrap">
<span class="search-icon">🔍</span>
<input
type="text"
bind:value={searchQuery}
placeholder="Поиск по названию..."
class="search-input"
/>
{#if searchQuery}
<button class="clear-btn" on:click={() => searchQuery = ''}>✕</button>
{/if}
</div>
<!--фильтр по категории-->
<div class="filter-group">
<span class="filter-label">Протокол:</span>
<div class="tags">
{#each categories as cat}
<button
class="tag"
class:active={activeCategory === cat}
on:click={() => activeCategory = cat}
>{cat}</button>
{/each}
</div>
</div>
<!--фильтр по сложности-->
<div class="filter-group">
<span class="filter-label">Сложность:</span>
<div class="tags">
{#each difficulties as diff}
<button
class="tag diff"
class:active={activeDifficulty === diff}
style={activeDifficulty === diff && diff !== 'Все'
? `background:${difficultyColor(diff)};color:white;border-color:${difficultyColor(diff)};`
: ''}
on:click={() => activeDifficulty = diff}
>{diff}</button>
{/each}
</div>
</div>
<!--сброс-->
{#if searchQuery || activeCategory !== 'Все' || activeDifficulty !== 'Все'}
<button class="reset-btn" on:click={clearFilters}>Сбросить фильтры</button>
{/if}
</div>
<!--счётчик результатов-->
<div class="results-count">
{#if filtered.length === lessons.length}
Всего уроков: {lessons.length}
{:else}
Найдено: {filtered.length} из {lessons.length}
{/if}
</div>
<!--список уроков-->
<div class="lessons-list">
{#each filtered as lesson (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">
<h3>{lesson.title}</h3>
{#if status === 'completed'}
<span class="status-badge done">✅ Пройден</span>
{:else if status === 'in-progress'}
<span class="status-badge progress">В процессе</span>
{/if}
</div>
<div class="lesson-footer">
<span class="tag-badge category">{lesson.category}</span>
<span class="tag-badge difficulty" style="background:{difficultyColor(lesson.difficulty)}20;color:{difficultyColor(lesson.difficulty)};border-color:{difficultyColor(lesson.difficulty)}40;">
{lesson.difficulty}
</span>
</div>
</a>
{:else}
<div class="empty">
Ничего не найдено. <button class="link-btn" on:click={clearFilters}>Сбросить фильтры</button>
</div>
{/each}
</div>
</div>
<style>
.page {
max-width: 860px;
margin: 0 auto;
padding: 30px 20px 60px;
}
/*шапка*/
.page-header {
text-align: center;
margin-bottom: 32px;
}
.page-header h1 {
color: #2196F3;
margin: 0 0 8px;
font-size: 1.8em;
}
.subtitle {
color: #777;
margin: 0 0 12px;
font-size: 0.95em;
}
.progress-summary {
display: inline-block;
background: #e8f5e9;
color: #2e7d32;
padding: 6px 16px;
border-radius: 20px;
font-size: 0.9em;
}
/*controls*/
.controls {
background: #f9f9f9;
border: 1px solid #e8e8e8;
border-radius: 12px;
padding: 18px 20px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
/*поиск*/
.search-wrap {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 12px;
font-size: 0.9em;
pointer-events: none;
}
.search-input {
width: 100%;
padding: 9px 36px 9px 36px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 0.95em;
background: white;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.search-input:focus { border-color: #2196F3; }
.clear-btn {
position: absolute;
right: 10px;
background: none;
border: none;
cursor: pointer;
color: #aaa;
font-size: 0.9em;
padding: 2px 6px;
}
.clear-btn:hover { color: #555; }
/*группа фильтров*/
.filter-group {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.filter-label {
font-size: 0.82em;
font-weight: 600;
color: #555;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
min-width: 72px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
padding: 4px 12px;
border: 1px solid #ddd;
border-radius: 20px;
background: white;
font-size: 0.85em;
cursor: pointer;
color: #555;
transition: all 0.15s;
}
.tag:hover { border-color: #2196F3; color: #2196F3; }
.tag.active {
background: #2196F3;
color: white;
border-color: #2196F3;
}
.reset-btn {
align-self: flex-start;
background: none;
border: 1px solid #f44336;
color: #f44336;
padding: 5px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.85em;
transition: all 0.15s;
}
.reset-btn:hover { background: #f44336; color: white; }
/* счётчик*/
.results-count {
font-size: 0.82em;
color: #999;
margin-bottom: 12px;
padding-left: 4px;
}
/*карточки*/
.lessons-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.lesson-card {
display: block;
border: 1px solid #e0e0e0;
border-radius: 10px;
padding: 18px 20px;
text-decoration: none;
color: inherit;
background: white;
transition: all 0.18s;
}
.lesson-card:hover {
border-color: #2196F3;
box-shadow: 0 3px 10px rgba(33,150,243,0.15);
transform: translateY(-2px);
}
.lesson-card.done {
border-left: 4px solid #4caf50;
}
.lesson-card.in-progress {
border-left: 4px solid #ffc107;
}
.lesson-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.lesson-card h3 {
margin: 0;
color: #1565c0;
font-size: 1em;
flex-grow: 1;
line-height: 1.4;
}
.status-badge {
padding: 3px 10px;
border-radius: 12px;
font-size: 0.78em;
font-weight: bold;
white-space: nowrap;
margin-left: 12px;
}
.status-badge.done {
background: #e8f5e9;
color: #2e7d32;
}
.status-badge.progress {
background: #fff8e1;
color: #f57f17;
border: 1px solid #ffe082;
}
.lesson-footer {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag-badge {
padding: 3px 10px;
border-radius: 12px;
font-size: 0.78em;
font-weight: 500;
border: 1px solid transparent;
}
.tag-badge.category {
background: #ede7f6;
color: #6a1b9a;
}
/*пусто*/
.empty {
text-align: center;
padding: 40px;
color: #999;
font-size: 0.95em;
}
.link-btn {
background: none;
border: none;
color: #2196F3;
cursor: pointer;
font-size: inherit;
text-decoration: underline;
padding: 0;
}
@media (max-width: 600px) {
.filter-group { flex-direction: column; align-items: flex-start; }
.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>
+13
View 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
View 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 });
}
}
+68
View File
@@ -0,0 +1,68 @@
import { json } from '@sveltejs/kit';
import { AI_API_KEY, AI_API_URL, AI_MODEL } from '$env/static/private';
export async function POST({ request }) {
const body = await request.json();
const { lessonType, taskDescription, currentState } = body;
if (!lessonType || !taskDescription) {
return json({ hint: null, error: 'Недостаточно данных для подсказки' }, { status: 400 });
}
const prompt = buildPrompt(lessonType, taskDescription, currentState);
try {
const response = await fetch(AI_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${AI_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://localhost',
},
body: JSON.stringify({
model: AI_MODEL,
messages: [{ role: 'user', content: prompt }],
max_tokens: 300,
temperature: 0.3,
}),
});
if (!response.ok) {
const err = await response.text();
console.error('AI API error:', err);
return json({ hint: null, error: 'Сервис подсказок недоступен' }, { status: 502 });
}
const data = await response.json();
const choice = data.choices?.[0];
let hint = choice?.message?.content?.trim() || null;
const finishReason = choice?.finish_reason;
if (!hint) {
return json({ hint: null, error: 'Модель не вернула ответ — попробуйте ещё раз' });
}
if (finishReason === 'length') {
hint = hint + '…';
}
return json({ hint });
} catch (e) {
console.error('Fetch error:', e);
return json({ hint: null, error: 'Ошибка сети' }, { status: 500 });
}
}
function buildPrompt(lessonType, taskDescription, currentState) {
return `Ты помощник в образовательном веб-приложении для изучения сетевых протоколов.
Отвечай ТОЛЬКО на русском языке. Ответ должен быть коротким максимум 2-3 предложения.
Не используй markdown, не пиши заголовков. Никаких рассуждений, размышлений, "подумаем", "итак", "значит".
Сразу готовая подсказка без вступлений. Пиши просто и понятно для начинающего.
Задание: "${taskDescription}"
Тип задания: ${lessonType}
Состояние пользователя: ${JSON.stringify(currentState)}
Дай короткую подсказу - как двигаться к решению, НЕ называя конечный ответ прямо.
Можно намекнуть на нужный байт, бит или концепцию.`;
}
+130
View 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 });
}
+14
View File
@@ -0,0 +1,14 @@
import { lessons } from '$lib/data/lessons';
import { error } from '@sveltejs/kit';
export function load({ params }) {
const lesson = lessons.find(l => l.slug === params.slug);
if (!lesson) {
throw error(404, 'Урок не найден');
}
return {
lesson
};
}
+452
View File
@@ -0,0 +1,452 @@
<script>
import { page } from '$app/stores';
import { lessons } from '$lib/data/lessons';
import { progressStorage } from '$lib/utils/storage';
import { onMount } from 'svelte';
import { processBuffer } from '$lib/utils/bufferProcessor';
import { generateTask, buildCurrentState } from '$lib/utils/taskGenerator';
import HexViewer from '$lib/components/HexViewer.svelte';
import LessonLayout from '$lib/components/LessonLayout.svelte';
import Notification from '$lib/components/Notification.svelte';
import BitEditor from '$lib/components/BitEditor.svelte';
import HexEditor from '$lib/components/HexEditor.svelte';
import WiresharkView from '$lib/components/WiresharkView.svelte';
import HttpEditor from '$lib/components/HttpEditor.svelte';
const TASKS_REQUIRED = 3; //сколько правильных ответов для завершения урока
let currentBuffer;
let notification = { show: false, message: '', type: 'success' };
let lesson = null;
let lessonComponent = null;
let additionalComponents = [];
let readOnlyRanges = [];
let aiMessage = null; //текст от модели
let aiLoading = false; //ждём ответ
let aiError = null; //сообщение об ошибке сети
let currentTask = null; //
let tasksCompleted = 0; //счётчик правильных ответов (из storage)
let taskJustSolved = false; //защита от двойного счёта при повторном "Проверить"
let currentTaskNum = 1;
$: isCompleted = tasksCompleted >= TASKS_REQUIRED;
$: tasksLabel = `${currentTaskNum} / ${TASKS_REQUIRED}`;
//показываем "Новое задание" только если задача решена
$: showNextBtn = taskJustSolved;
let mounted = false;
onMount(() => { mounted = true; });
$: if (mounted) loadLesson($page.params.slug);
function loadLesson(slug) {
const found = lessons.find(l => l.slug === slug) ?? null;
if (!found || lesson?.slug === slug) return;
lesson = found;
lessonComponent = getLessonComponent(lesson.component);
additionalComponents = getAdditionalComponents(lesson.additionalComponents ?? []);
readOnlyRanges = lesson.readOnlyRanges ?? [];
tasksCompleted = progressStorage.getTasksCompleted(lesson.id.toString());
currentTaskNum = tasksCompleted + 1;
taskJustSolved = false;
notification = { show: false, message: '', type: 'success' };
loadNextTask(true);
}
function loadNextTask(isInitial = false) {
if (!isInitial) currentTaskNum = tasksCompleted + 1;
taskJustSolved = false;
aiMessage = null;
aiError = null;
currentTask = generateTask(lesson.taskTemplate);
const raw = new Uint8Array(currentTask.initialBuffer);
currentBuffer = processBuffer(raw, lesson.id);
}
//маппинг компонентов
function getLessonComponent(name) {
switch (name) {
case 'BitEditor': return BitEditor;
case 'HexEditor': return HexEditor;
case 'HttpEditor': return HttpEditor;
default: return null;
}
}
function getAdditionalComponents(names) {
return names.map(name => {
switch (name) {
case 'HexViewer': return HexViewer;
case 'WiresharkView': return WiresharkView;
default: return null;
}
}).filter(Boolean);
}
//обработчики
function handleBufferChange(newBuffer) {
currentBuffer = lesson
? processBuffer(newBuffer, lesson.id)
: newBuffer;
//if (aiMessage) { aiMessage = null; aiError = null; }
}
function checkSolution() {
if (!currentTask) return;
const ok = currentTask.validate(currentBuffer);
if (ok) {
//cчитаем только один раз за попытку — защита от спама "Проверить"
if (!taskJustSolved) {
tasksCompleted += 1;
progressStorage.saveTasksCompleted(lesson.id.toString(), tasksCompleted, TASKS_REQUIRED);
}
taskJustSolved = true;
aiMessage = null;
aiError = null;
const justReached = tasksCompleted === TASKS_REQUIRED;
const msg = justReached
? `Правильно! 🎉 Урок завершён! (${tasksCompleted} / ${TASKS_REQUIRED})`
: `Правильно! 🎉 Выполнено ${tasksCompleted} / ${TASKS_REQUIRED}`;
showNotification(msg, 'success');
} else {
taskJustSolved = false;
showNotification('Пока неверно. Попробуйте ещё раз!', 'error');
}
}
function nextTask() {
loadNextTask();
}
//AI-запросы
async function requestHint() {
if (!currentTask?.aiContext || aiLoading) return;
aiLoading = true;
aiMessage = null;
aiError = null;
const currentState = buildCurrentState(currentBuffer, currentTask.aiContext);
try {
const res = await fetch('/api/hint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lessonType: currentTask.aiContext.type,
taskDescription: currentTask.objective,
currentState,
}),
});
const data = await res.json();
if (data.error) {
aiError = data.error;
} else {
aiMessage = data.hint;
}
} catch {
aiError = 'Не удалось связаться с сервером подсказок.';
} finally {
aiLoading = false;
}
}
function showNotification(message, type = 'success') {
notification = { show: true, message, type };
}
function hideNotification() {
notification = { ...notification, show: false };
}
//вспомогательная функция: нужно ли передавать wiresharkFields
function isWiresharkView(Component) {
return Component === WiresharkView;
}
</script>
{#if lesson}
<LessonLayout {lesson}>
<div class="lesson-content">
<!--задание + прогресс-->
<div class="task-header">
<div class="task-objective">
<span class="task-label">Задание</span>
{#if tasksCompleted >= 0}
<span class="task-counter" class:done={isCompleted}>{tasksLabel}</span>
{/if}
</div>
{#if currentTask}
<p class="objective-text">{currentTask.objective}</p>
{/if}
</div>
<!--прогресс-бар-->
<div class="progress-bar-wrap">
<div
class="progress-bar-fill"
class:done={isCompleted}
style="width: {isCompleted ? 100 : Math.min(tasksCompleted / TASKS_REQUIRED * 100, 100)}%"
></div>
</div>
<!--основной редактор-->
{#key currentTaskNum}
<svelte:component
this={lessonComponent}
bind:buffer={currentBuffer}
onBufferChange={handleBufferChange}
readOnlyRanges={readOnlyRanges}
/>
{/key}
<!--дополнительные компоненты (HexViewer, WiresharkView, ...)-->
{#each additionalComponents as Component}
{#if currentBuffer}
{#if isWiresharkView(Component)}
<!--WiresharkView получает описание полей из данных урока-->
<svelte:component
this={Component}
data={currentBuffer}
fields={lesson.wiresharkFields ?? []}
title={lesson.wiresharkTitle ?? 'Структура пакета'}
/>
{:else}
<svelte:component this={Component} data={currentBuffer} />
{/if}
{/if}
{/each}
<!--AI-сообщение-->
{#if aiLoading}
<div class="ai-block loading">
<span class="ai-icon">🤖</span>
<span class="ai-text ai-thinking">Думаю над подсказкой...</span>
</div>
{:else if aiMessage}
<div class="ai-block">
<span class="ai-icon">🤖</span>
<span class="ai-text">{aiMessage}</span>
</div>
{:else if aiError}
<div class="ai-block error">
<span class="ai-icon">⚠️</span>
<span class="ai-text">{aiError}</span>
</div>
{/if}
<!--кнопки управления-->
<div class="controls">
<button on:click={checkSolution} class="check-button">
Проверить решение
</button>
{#if showNextBtn}
<button on:click={nextTask} class="next-button">
Новое задание →
</button>
{/if}
{#if !taskJustSolved}
<button on:click={requestHint} class="hint-button" disabled={aiLoading}>
{aiLoading ? '...' : '💡 Подсказка'}
</button>
{/if}
</div>
<!--завершение урока-->
{#if isCompleted}
<div class="completion-badge">
✅ Урок завершен! Решено заданий: {tasksCompleted}
</div>
{/if}
</div>
</LessonLayout>
{:else}
<p>Урок не найден</p>
{/if}
{#if notification.show}
<Notification
message={notification.message}
type={notification.type}
onClose={hideNotification}
/>
{/if}
<style>
.lesson-content {
max-width: 800px;
margin: 0 auto;
}
/*задание*/
.task-header {
background: #fffcf6;
border-left: 4px solid #FF9800;
border-radius: 4px;
padding: 14px 16px;
margin-bottom: 10px;
}
.task-objective {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.task-label {
font-weight: bold;
font-size: 0.85em;
text-transform: uppercase;
color: #e65100;
letter-spacing: 0.04em;
}
.task-counter {
background: #FF9800;
color: white;
font-size: 0.75em;
font-weight: bold;
padding: 2px 8px;
border-radius: 10px;
}
.task-counter.done {
background: #4caf50;
}
.objective-text {
margin: 0;
font-size: 1em;
color: #333;
line-height: 1.5;
}
/*прогресс-бар*/
.progress-bar-wrap {
height: 4px;
background: #e0e0e0;
border-radius: 2px;
margin-bottom: 16px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: #4caf50;
border-radius: 2px;
transition: width 0.4s ease;
}
/*AI-блок*/
.ai-block {
display: flex;
gap: 10px;
align-items: flex-start;
background: #f0f4ff;
border-left: 4px solid #3f51b5;
border-radius: 4px;
padding: 12px 14px;
margin: 12px 0;
font-size: 0.92em;
line-height: 1.5;
}
.ai-block.loading {
background: #f5f5f5;
border-color: #bbb;
}
.ai-block.error {
background: #fff3e0;
border-color: #ff9800;
}
.ai-icon {
font-size: 1.1em;
flex-shrink: 0;
margin-top: 1px;
}
.ai-text {
color: #333;
}
.ai-thinking {
color: #888;
font-style: italic;
}
/*кнопки*/
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 16px 0;
}
.check-button {
background: #4caf50;
color: white;
border: none;
padding: 11px 22px;
border-radius: 6px;
cursor: pointer;
font-size: 1em;
}
.check-button:hover { background: #388e3c; }
.next-button {
background: #2196f3;
color: white;
border: none;
padding: 11px 22px;
border-radius: 6px;
cursor: pointer;
font-size: 1em;
font-weight: 500;
}
.next-button:hover { background: #1565c0; }
.hint-button {
background: #ff9800;
color: white;
border: none;
padding: 11px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 1em;
}
.hint-button:hover:not(:disabled) { background: #f57c00; }
.hint-button:disabled { opacity: 0.6; cursor: default; }
.completion-badge {
background: #e8f5e9;
border: 2px solid #4caf50;
padding: 16px;
border-radius: 8px;
text-align: center;
font-weight: bold;
color: #2e7d32;
margin: 20px 0;
font-size: 1.05em;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
export async function load({ url }) {
const errorCode = url.searchParams.get('error');
const error = errorCode === 'CredentialsSignin'
? 'Неверный логин или пароль'
: null;
return { error };
}
+292
View 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
View 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
View 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>
+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+13
View File
@@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});