Compare commits
6
Commits
fe5cd4c685
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeb4a39506 | ||
|
|
bdecc30299 | ||
|
|
cc03166579 | ||
|
|
3c1c263c41 | ||
|
|
5e75f2d84b | ||
|
|
082867f9f2 |
+23
@@ -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,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-ваш_ключ_здесь
|
||||
|
||||
Без этого файла приложение работает, но кнопка «Подсказка» будет недоступна.
|
||||
@@ -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
|
||||
}
|
||||
Generated
+4248
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
@@ -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>Шестнадцатеричная запись удобна: каждая цифра (0–F) точно соответствует 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> 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;">46–1500 байт</td>
|
||||
<td style="padding:8px 12px;border:1px solid #ddd;text-align:center;">4 байта</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p><strong>Dst MAC</strong> — получатель. <strong>Src MAC</strong> — отправитель.</p>
|
||||
<p><strong>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>Байты 2–3: Total Length — полная длина пакета</div>
|
||||
<div>Байты 4–5: Identification — ID для сборки фрагментов</div>
|
||||
<div>Байты 6–7: Flags (3 бит) + Fragment Offset (13 бит)</div>
|
||||
<div>Байт 8: TTL — уменьшается на 1 на каждом маршрутизаторе</div>
|
||||
<div>Байт 9: Protocol — TCP=6, UDP=17, ICMP=1</div>
|
||||
<div>Байты 10–11: Header Checksum (пересчитывается автоматически)</div>
|
||||
<div>Байты 12–15: Source IP</div>
|
||||
<div>Байты 16–19: 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}">Поля байтов 6–7 (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>Биты 12–0: 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>Байты 0–1: Source Port</div>
|
||||
<div>Байты 2–3: Destination Port</div>
|
||||
<div>Байты 4–7: Sequence Number — номер первого байта данных</div>
|
||||
<div>Байты 8–11: Acknowledgment Number — следующий ожидаемый байт</div>
|
||||
<div>Байт 12: Data Offset (длина заголовка, мин. 5 × 4 = 20 байт)</div>
|
||||
<div>Байт 13: Flags — SYN, ACK, FIN, RST, PSH...</div>
|
||||
<div>Байты 14–15: Window Size — размер буфера приёма</div>
|
||||
<div>Байты 16–17: Checksum (авто)</div>
|
||||
<div>Байты 18–19: 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-порты клиента: 49152–65535.</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>Байты 0–1: Source Port</div>
|
||||
<div>Байты 2–3: Destination Port</div>
|
||||
<div>Байты 4–5: Length — длина датаграммы (минимум 8)</div>
|
||||
<div>Байты 6–7: 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;"> ← пустая строка (ОБЯЗАТЕЛЬНА!)</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;"> ← пустая строка</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>Байты 0–1: Transaction ID — одинаковый в запросе и ответе</div>
|
||||
<div>Байты 2–3: Flags</div>
|
||||
<div>Байты 4–5: QDCOUNT — число вопросов</div>
|
||||
<div>Байты 6–7: ANCOUNT — число ответов (в запросе = 0)</div>
|
||||
<div>Байты 8–9: NSCOUNT — авторитативные серверы</div>
|
||||
<div>Байты 10–11: ARCOUNT — дополнительные записи</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Поле Flags (байты 2–3)</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}">3–0</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> 07 65 78 61 6D 70 6C 65 (длина 7 + "example")</div>
|
||||
<div> 03 63 6F 6D (длина 3 + "com")</div>
|
||||
<div> 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>
|
||||
`,
|
||||
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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{};
|
||||
}
|
||||
}
|
||||
@@ -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?.()}
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function load({ locals }) {
|
||||
return { user: locals.user };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
Дай короткую подсказу - как двигаться к решению, НЕ называя конечный ответ прямо.
|
||||
Можно намекнуть на нужный байт, бит или концепцию.`;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
export async function load({ url }) {
|
||||
const errorCode = url.searchParams.get('error');
|
||||
const error = errorCode === 'CredentialsSignin'
|
||||
? 'Неверный логин или пароль'
|
||||
: null;
|
||||
return { error };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user