Initial hop. implementation and UGREEN NAS deployment guide
This commit is contained in:
88
scripts/configure-nas.sh
Normal file
88
scripts/configure-nas.sh
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/bin/sh
|
||||
# NAS에서 실행합니다. 기존 .env와 비밀값은 유지합니다.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
umask 077
|
||||
|
||||
hop_env_tmp=''
|
||||
hop_yaml_tmp=''
|
||||
hop_echo_hidden=false
|
||||
cleanup() {
|
||||
if [ "$hop_echo_hidden" = true ]; then stty echo; fi
|
||||
if [ -n "$hop_env_tmp" ]; then rm -f "$hop_env_tmp"; fi
|
||||
if [ -n "$hop_yaml_tmp" ]; then rm -f "$hop_yaml_tmp"; fi
|
||||
}
|
||||
trap cleanup 0
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 143' TERM
|
||||
|
||||
if ! docker compose config --help | grep -q -- '--no-env-resolution'; then
|
||||
printf '%s\n' 'Docker 앱/Compose를 업데이트하세요. --no-env-resolution 옵션이 필요합니다.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
command -v openssl >/dev/null || {
|
||||
printf '%s\n' 'openssl 명령이 필요합니다.' >&2
|
||||
exit 1
|
||||
}
|
||||
printf 'NAS의 실제 내부 IPv4 주소를 입력하고 Enter: '
|
||||
read -r hop_nas_ip
|
||||
if ! printf '%s\n' "$hop_nas_ip" | awk -F. '
|
||||
NF != 4 { exit 1 }
|
||||
{ for (i = 1; i <= 4; i++) if ($i !~ /^[0-9]+$/ || $i > 255) exit 1 }
|
||||
'; then
|
||||
printf '%s\n' 'NAS의 IPv4 주소를 입력하세요.' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Resend API 키를 입력하고 Enter (건너뛰려면 Enter): '
|
||||
if [ -t 0 ]; then
|
||||
stty -echo
|
||||
hop_echo_hidden=true
|
||||
fi
|
||||
read -r hop_resend_key
|
||||
if [ "$hop_echo_hidden" = true ]; then
|
||||
stty echo
|
||||
hop_echo_hidden=false
|
||||
fi
|
||||
printf '\n'
|
||||
case "$hop_resend_key" in
|
||||
*[!A-Za-z0-9_-]*)
|
||||
printf '%s\n' 'Resend API 키에는 공백이나 따옴표를 넣지 마세요.' >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
hop_registration=closed
|
||||
if [ -n "$hop_resend_key" ]; then
|
||||
printf '회원가입을 활성화할까요? y 입력 후 Enter / 비활성화는 Enter: '
|
||||
read -r hop_open_registration
|
||||
case "$hop_open_registration" in y|Y) hop_registration=open ;; esac
|
||||
fi
|
||||
hop_session_secret=$(openssl rand -hex 32)
|
||||
hop_discovery_secret=$(openssl rand -hex 32)
|
||||
hop_env_tmp=$(mktemp .env.nas.XXXXXX)
|
||||
awk '!/^(HOP_BIND_IP|HOP_HOST_PORT|HOP_CONTAINER_PORT|APP_URL|SESSION_SECRET|DISCOVERY_SECRET|RESEND_API_KEY|REGISTRATION_MODE)=/' .env.example > "$hop_env_tmp"
|
||||
{
|
||||
printf 'HOP_BIND_IP=%s\n' "$hop_nas_ip"
|
||||
printf 'HOP_HOST_PORT=50189\nHOP_CONTAINER_PORT=50189\n'
|
||||
printf 'APP_URL=https://hop.noname.company\n'
|
||||
printf 'SESSION_SECRET=%s\n' "$hop_session_secret"
|
||||
printf 'DISCOVERY_SECRET=%s\n' "$hop_discovery_secret"
|
||||
printf 'RESEND_API_KEY=%s\n' "$hop_resend_key"
|
||||
printf 'REGISTRATION_MODE=%s\n' "$hop_registration"
|
||||
} >> "$hop_env_tmp"
|
||||
mv "$hop_env_tmp" .env
|
||||
hop_env_tmp=''
|
||||
unset hop_session_secret hop_discovery_secret hop_resend_key
|
||||
else
|
||||
printf '%s\n' '기존 .env를 유지합니다. 비밀값을 다시 생성하지 않습니다.'
|
||||
fi
|
||||
|
||||
chmod 600 .env
|
||||
hop_yaml_tmp=$(mktemp .compose.nas.XXXXXX)
|
||||
docker compose --project-name hop --project-directory "$PWD" --env-file .env -f compose.yaml config --no-env-resolution > "$hop_yaml_tmp"
|
||||
mv "$hop_yaml_tmp" compose.nas.yaml
|
||||
hop_yaml_tmp=''
|
||||
chmod 600 compose.nas.yaml
|
||||
printf '\n%s\n' '완료. Docker 앱에서 프로젝트 이름 hop으로 아래 YAML을 불러오세요.'
|
||||
printf '%s/compose.nas.yaml\n' "$PWD"
|
||||
35
scripts/dev-runtime.mjs
Normal file
35
scripts/dev-runtime.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createServer } from 'vite';
|
||||
import { loadConfig } from '../apps/server/src/config.ts';
|
||||
import { createApp } from '../apps/server/src/app.ts';
|
||||
const cfg = loadConfig({
|
||||
...process.env,
|
||||
NODE_ENV: 'development',
|
||||
SAME_NETWORK_DISCOVERY_ENABLED: 'true',
|
||||
PAIR_REQUESTS_PER_MINUTE: '20',
|
||||
GUEST_REQUESTS_PER_MINUTE: '60',
|
||||
IP_REQUESTS_PER_MINUTE: '120',
|
||||
PORT: process.env.HOP_DEV_API_PORT,
|
||||
DATABASE_PATH: process.env.HOP_DEV_DATABASE_PATH ?? '.data/hop.sqlite',
|
||||
UPLOAD_DIR: process.env.HOP_DEV_UPLOAD_DIR ?? '.data/uploads',
|
||||
});
|
||||
const runtime = await createApp(cfg);
|
||||
await runtime.app.listen({ port: cfg.port, host: '127.0.0.1' });
|
||||
process.env.HOP_API_URL = `http://127.0.0.1:${cfg.port}`;
|
||||
const vite = await createServer({
|
||||
root: 'apps/web',
|
||||
configFile: 'apps/web/vite.config.js',
|
||||
server: { port: Number(process.env.HOP_DEV_WEB_PORT), strictPort: true, host: '127.0.0.1' },
|
||||
});
|
||||
await vite.listen();
|
||||
const url = vite.resolvedUrls.local[0];
|
||||
runtime.setDevOrigin(new URL(url).origin);
|
||||
cfg.appUrl = new URL(url).origin;
|
||||
process.stdout.write(
|
||||
`\nhop. 개발 화면: ${url}\nAPI: ${process.env.HOP_API_URL}\n휴대폰 접속은 아직 설정하지 않았습니다. QR 대신 같은 PC의 다른 브라우저에서 코드를 입력해 주세요.\n`,
|
||||
);
|
||||
for (const signal of ['SIGINT', 'SIGTERM'])
|
||||
process.on(signal, async () => {
|
||||
await vite.close();
|
||||
await runtime.app.close();
|
||||
process.exit(0);
|
||||
});
|
||||
49
scripts/dev.mjs
Normal file
49
scripts/dev.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import { loadEnvFile } from 'node:process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { spawn } from 'node:child_process';
|
||||
if (existsSync('.env')) loadEnvFile('.env');
|
||||
async function availablePort() {
|
||||
const server = createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw Error('No development port');
|
||||
const port = address.port;
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve())),
|
||||
);
|
||||
return port;
|
||||
}
|
||||
const build = spawn('npm', ['run', 'build', '-w', '@hop/shared'], { stdio: 'inherit' });
|
||||
if ((await new Promise((resolve) => build.on('exit', resolve))) !== 0) process.exit(1);
|
||||
const apiPort = await availablePort(),
|
||||
webPort = await availablePort();
|
||||
if (apiPort === webPort) throw Error('Port allocation conflicted; run npm run dev again.');
|
||||
const child = spawn(
|
||||
'node',
|
||||
[
|
||||
'--watch',
|
||||
'--watch-path=apps/server/src',
|
||||
'--watch-path=apps/server/drizzle',
|
||||
'--watch-path=packages/shared/dist',
|
||||
'--watch-path=scripts/dev-runtime.mjs',
|
||||
'--watch-preserve-output',
|
||||
'--import',
|
||||
'tsx',
|
||||
'scripts/dev-runtime.mjs',
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
HOP_DEV_API_PORT: String(apiPort),
|
||||
HOP_DEV_WEB_PORT: String(webPort),
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
},
|
||||
);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal));
|
||||
child.on('exit', (code) => process.exit(code ?? 0));
|
||||
75
scripts/seed-test-accounts.mjs
Normal file
75
scripts/seed-test-accounts.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
import { loadEnvFile } from 'node:process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { resolve } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { loadConfig } from '../apps/server/src/config.ts';
|
||||
import { createApp } from '../apps/server/src/app.ts';
|
||||
import { users } from '../apps/server/src/db/schema.ts';
|
||||
|
||||
try {
|
||||
loadEnvFile('.env');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
if (process.env.NODE_ENV === 'production') throw Error('개발 환경에서만 실행할 수 있습니다.');
|
||||
const cfg = loadConfig({
|
||||
...process.env,
|
||||
NODE_ENV: 'development',
|
||||
DATABASE_PATH: process.env.HOP_DEV_DATABASE_PATH ?? resolve('.data/hop.sqlite'),
|
||||
UPLOAD_DIR: process.env.HOP_DEV_UPLOAD_DIR ?? resolve('.data/uploads'),
|
||||
});
|
||||
const runtime = await createApp(cfg);
|
||||
try {
|
||||
const password = '1234';
|
||||
const passwordHash = await runtime.service.auth.password(password);
|
||||
const accounts = [1, 2].map((n) => ({
|
||||
email: `test${n}@hop.test`,
|
||||
displayName: `hop. 테스트 ${n}`,
|
||||
}));
|
||||
// Only these named development fixtures may have their passwords reset.
|
||||
for (const account of accounts) {
|
||||
const existing = runtime.service.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, account.email))
|
||||
.get();
|
||||
if (existing && (existing.displayName !== account.displayName || existing.role !== 'user'))
|
||||
throw Error(`테스트 계정이 아닌 기존 계정입니다: ${account.email}`);
|
||||
}
|
||||
runtime.service.db.transaction(() => {
|
||||
for (const account of accounts) {
|
||||
const existing = runtime.service.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, account.email))
|
||||
.get();
|
||||
if (existing)
|
||||
runtime.service.db
|
||||
.update(users)
|
||||
.set({ passwordHash })
|
||||
.where(eq(users.id, existing.id))
|
||||
.run();
|
||||
else
|
||||
runtime.service.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
...account,
|
||||
passwordHash,
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
verifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
});
|
||||
for (const account of accounts) {
|
||||
const result = await runtime.service.auth.login(account.email, password, 'local-test-seed');
|
||||
if (!result.account.verified) throw Error('인증 상태 검증 실패');
|
||||
runtime.service.auth.logout(result.token);
|
||||
console.log(`${account.email}: 생성 및 로그인 검증 완료`);
|
||||
}
|
||||
} finally {
|
||||
await runtime.app.close();
|
||||
}
|
||||
Reference in New Issue
Block a user