Initial commit: FreeBSD deployment complete

This commit is contained in:
2026-02-10 13:35:05 +09:00
commit e0ee7b4225
16 changed files with 3179 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# OneTake Updater
node_modules/
npm-debug.log
yarn-error.log
# Build Artifacts
*.zip
*.apk
*.exe
dist/
# Temporary Files
tmp/
.vscode/
.DS_Store
Thumbs.db
# IDE
.agent/
.gemini/
# PHP Portal
php-portal/pkgs/*
!php-portal/pkgs/.gitkeep
php-portal/version.json
# SSL Certificates
*.pem
!php-portal/private_key.pem
!php-portal/public_key.pem

272
LICENSE_SYSTEM.md Normal file
View File

@ -0,0 +1,272 @@
# 🎫 OneTake Updater - QR 라이선스 시스템
## 📋 개요
OneTake Updater는 **JWT 서명 기반의 QR 코드 라이선스 시스템**을 제공합니다.
프로젝트별로 라이선스를 발급하고, 만료일, 디바이스 제한, 연결 기기 수 등을 관리할 수 있습니다.
---
## 🔑 라이선스 구조
### 1. 라이선스 토큰 구성
라이선스는 **Base64 인코딩된 JSON** 형태로 발급됩니다:
```json
{
"d": "{라이선스 데이터 JSON}",
"s": "{RSA-SHA256 서명 (Base64)}"
}
```
### 2. 라이선스 데이터 필드
| 필드 | 타입 | 설명 | 예시 |
|------|------|------|------|
| `expiry` | String | 만료일 (YYYY-MM-DD) | `"2027-02-06"` |
| `deviceId` | String | 허용된 디바이스 ID<br>(`*` = 모든 기기) | `"ABC123"` 또는 `"*"` |
| `projectName` | String | 프로젝트/카테고리 이름 | `"MYPROJECT"` |
| `tvLimit` | Integer | 최대 연결 가능 기기 수<br>(0 = 무제한) | `5` |
| `issuedAt` | Integer | 발급 시각 (Unix timestamp, ms) | `1738838400000` |
| `type` | String | 라이선스 타입 | `"standard"` |
---
## 🎨 라이선스 발급 방법
### 포털에서 발급
1. **관리자 로그인**`라이선스 발급` 탭 이동
2. 다음 정보 입력:
- **만료일**: 라이선스 유효 기간
- **프로젝트 이름**: 대분류 카테고리 선택 또는 직접 입력
- **Device ID**: 특정 기기만 허용하려면 입력, 모든 기기 허용 시 `*`
- **기기 연결 제한**: 동시 연결 가능한 수신기(TV 등) 대수 (0 = 무제한)
3. **QR 생성하기** 버튼 클릭
4. QR 코드 모달 팝업 확인
### 발급 예시
```
만료일: 2027-12-31
프로젝트: MYPROJECT
Device ID: * (모든 기기)
연결 제한: 3대
```
**생성된 토큰 예시:**
```
eyJkIjoie1wiZXhwaXJ5XCI6XCIyMDI3LTEyLTMxXCIsXCJkZXZpY2VJZFwiOlwiKlwiLFwicHJvamVjdE5hbWVcIjpcIk1ZUFJPSkVDVFwiLFwidHZMaW1pdFwiOjMsXCJpc3N1ZWRBdFwiOjE3Mzg4Mzg0MDAwMDAsXCJ0eXBlXCI6XCJzdGFuZGFyZFwifSIsInMiOiJhYmMxMjMuLi4ifQ==
```
---
## 📤 라이선스 배포 방법
### 1. QR 코드 스캔 (권장)
- 모바일 앱에서 QR 스캔 기능 구현
- 스캔 즉시 토큰 문자열 획득
### 2. 카카오톡 공유
포털에서 **"카카오톡으로 전송"** 버튼 클릭 시:
- QR 이미지와 함께 라이선스 정보 전송
- 수신자가 QR 코드를 스캔하여 사용
**카카오톡 메시지 구성:**
```
[MYPROJECT] 라이선스 발급
📅 만료일: 2027-12-31
📱 연결제한: 3대
[QR 코드 이미지]
```
### 3. 텍스트 복사
- QR 모달 하단의 토큰 문자열을 복사
- 이메일, 메신저 등으로 전달
- 수신자가 앱에 직접 붙여넣기
---
## 🔐 라이선스 검증 로직 (클라이언트 구현 가이드)
### 1. 토큰 파싱
```javascript
// Base64 디코딩
const decoded = JSON.parse(atob(token));
const licenseData = JSON.parse(decoded.d);
const signature = atob(decoded.s);
```
### 2. 서명 검증 (RSA-SHA256)
```javascript
// public_key.pem을 사용하여 서명 검증
const isValid = crypto.verify(
'sha256',
Buffer.from(decoded.d),
publicKey,
Buffer.from(signature)
);
```
### 3. 만료일 체크
```javascript
const expiryDate = new Date(licenseData.expiry);
const now = new Date();
if (now > expiryDate) {
throw new Error('라이선스가 만료되었습니다.');
}
```
### 4. 디바이스 ID 검증
```javascript
const myDeviceId = getDeviceId(); // 기기 고유 ID 획득
if (licenseData.deviceId !== '*' && licenseData.deviceId !== myDeviceId) {
throw new Error('이 기기에서 사용할 수 없는 라이선스입니다.');
}
```
### 5. 연결 제한 확인
```javascript
const connectedDevices = getCurrentConnectedCount(); // 현재 연결된 기기 수
if (licenseData.tvLimit > 0 && connectedDevices >= licenseData.tvLimit) {
throw new Error(`최대 ${licenseData.tvLimit}대까지 연결 가능합니다.`);
}
```
---
## 🛡️ 보안 고려사항
### 1. 키 관리
- **private_key.pem**: 서버에만 보관, 절대 외부 유출 금지
- **public_key.pem**: 클라이언트 앱에 포함 (검증용)
### 2. 토큰 저장
- 클라이언트는 토큰을 안전한 저장소에 보관 (Keychain, EncryptedSharedPreferences 등)
- 네트워크 전송 시 HTTPS 사용 필수
### 3. 재발급 정책
- 만료된 라이선스는 재발급 필요
- 분실 시 관리자가 새 토큰 발급
---
## 📊 사용 시나리오
### 시나리오 1: 단일 기기 라이선스
```
만료일: 2027-12-31
Device ID: DEVICE-ABC-123
연결 제한: 1대
```
→ 특정 태블릿 1대에서만 사용 가능, TV 1대 연결 가능
### 시나리오 2: 무제한 라이선스
```
만료일: 2099-12-31
Device ID: *
연결 제한: 0 (무제한)
```
→ 모든 기기에서 사용 가능, 연결 제한 없음
### 시나리오 3: 프로젝트 단위 라이선스
```
만료일: 2027-06-30
Device ID: *
프로젝트: COMPANY-A
연결 제한: 10대
```
→ COMPANY-A 프로젝트 전용, 최대 10대 TV 연결 가능
---
## 🔧 API 엔드포인트
### 라이선스 발급 (관리자 전용)
```http
POST /index.php?action=gen_license
Content-Type: multipart/form-data
expiry=2027-12-31
deviceId=*
projectName=MYPROJECT
tvLimit=5
```
**응답:**
```json
{
"success": true,
"token": "eyJkIjoie1wiZXhwaXJ5XCI6..."
}
```
### QR 이미지 생성 (공개)
```http
GET /index.php?action=qr_img&data={토큰}
```
→ QR 코드 PNG 이미지 반환 (외부 API 프록시)
---
## 📱 클라이언트 구현 체크리스트
- [ ] QR 스캔 기능 구현
- [ ] 토큰 파싱 로직 구현
- [ ] RSA-SHA256 서명 검증 구현
- [ ] 만료일 체크 로직 구현
- [ ] 디바이스 ID 검증 로직 구현
- [ ] 연결 제한 관리 로직 구현
- [ ] 라이선스 갱신 알림 UI 구현
- [ ] 에러 처리 및 사용자 안내 메시지 구현
---
## 🆘 FAQ
### Q1. 라이선스가 만료되면 어떻게 되나요?
A. 앱이 실행되지 않거나 제한된 기능만 사용 가능합니다. 관리자에게 연락하여 새 라이선스를 발급받아야 합니다.
### Q2. Device ID는 어떻게 확인하나요?
A. 앱 내 설정 화면에서 "기기 정보" 또는 "라이선스 정보"를 확인하면 표시됩니다.
### Q3. 연결 제한을 초과하면?
A. 새로운 기기 연결 시 "최대 연결 대수 초과" 오류가 발생합니다. 기존 연결을 해제하거나 라이선스를 업그레이드해야 합니다.
### Q4. 라이선스를 여러 명이 공유할 수 있나요?
A. Device ID가 `*`이고 연결 제한이 충분하다면 가능합니다. 단, 보안상 권장하지 않습니다.
### Q5. 서명 검증이 실패하면?
A. 토큰이 위조되었거나 손상되었을 가능성이 있습니다. 관리자에게 재발급을 요청하세요.
---
## 📞 지원
라이선스 관련 문의: update@onetake.best
포털 접속: https://update.onetake.best

119
README.md Normal file
View File

@ -0,0 +1,119 @@
# OneTake Updater
범용 프로젝트 업데이트 관리 시스템
## 📋 개요
OneTake Updater는 여러 프로젝트의 버전 관리와 업데이트 배포를 중앙에서 관리하는 웹 기반 시스템입니다.
## 🎯 주요 기능
### 1. 프로젝트 관리
- 다중 프로젝트 지원 (카테고리별 그룹화)
- 버전 정보 관리 (versionName, versionCode)
- 파일 업로드 (드래그 앤 드롭 지원)
- 실시간 업데이트 시간 표시
### 2. 파일 타입 자동 인식
- **플랫폼 배지**: ANDROID (.apk), WINDOWS (.exe), ARCHIVE (.zip)
- **빌드 타입 배지**: DEBUG, RELEASE, UNSIGNED
- 파일명 기반 자동 분류
### 3. REST API
- `publish_update` - 새 버전 업로드
- `check_update` - 버전 확인 (Public API)
- `version` - 전체 프로젝트 정보
### 4. 라이선스 관리
- JWT 기반 라이선스 토큰 생성
- QR 코드 자동 생성
- 카카오톡 공유 기능
## 📦 프로젝트 구조
```
onetakeupdater/
├── README.md
└── php-portal/ # PHP 기반 웹 포털
├── README.md # 포털 상세 가이드
├── index.php # 메인 포털 파일
├── version.json # 프로젝트 버전 데이터
├── pkgs/ # 업로드된 패키지 파일
├── private_key.pem # JWT 서명 키
└── public_key.pem # JWT 검증 키
```
## 🚀 빠른 시작
### 서버 배포
```bash
# 1. 파일 업로드
scp -r php-portal/* user@update.onetake.best:/home/update/html/
# 2. 권한 설정
chmod 755 /home/update/html
chmod 666 /home/update/html/version.json
chmod 755 /home/update/html/pkgs
```
### API 사용 예시
```bash
# 업데이트 발행
curl -F "api_key=YOUR_API_KEY" \
-F "id=project-id" \
-F "parent=CATEGORY" \
-F "name=Project Name" \
-F "versionName=v1.0.0" \
-F "versionCode=100" \
-F "file=@app.apk" \
"https://update.onetake.best/index.php?action=publish_update"
# 버전 확인
curl "https://update.onetake.best/index.php?action=check_update&id=project-id"
```
## 🔧 설정
### 관리자 계정 (index.php)
```php
$config = [
'admin_id' => 'admin',
'admin_pw' => password_hash('your_password', PASSWORD_DEFAULT),
'api_key' => 'your_api_key',
'version_file' => 'version.json',
'upload_dir' => 'pkgs/'
];
```
## 📊 관리 중인 프로젝트 예시
프로젝트별로 자유롭게 카테고리를 생성하여 관리할 수 있습니다.
## 🔒 보안
- 관리자 로그인 인증
- API 키 기반 업로드 인증
- JWT 라이선스 시스템
- 세션 기반 접근 제어
## 📝 버전
- Portal: v8.10
- Last Updated: 2026-02-06
## 📖 상세 문서
자세한 사용법은 `php-portal/README.md`를 참조하세요.
## 🌐 라이브 서버
- URL: https://update.onetake.best
- 포털: https://update.onetake.best/index.php
## 🛠️ 기술 스택
- **Backend**: PHP 7.4+
- **Frontend**: Vanilla JavaScript, HTML5, CSS3
- **Storage**: JSON 파일 기반
- **Security**: JWT, Password Hashing
- **Libraries**: QRCode.js, Kakao SDK

188
SERVER_INFO.md Normal file
View File

@ -0,0 +1,188 @@
# 서버 배포 정보
## 🌐 서버 접속 정보
### SSH 접속
```bash
Host: update.onetake.best
User: update
Port: 22
```
### 서버 경로
```
웹 루트: /home/update/html/
포털 파일: /home/update/html/index.php
버전 파일: /home/update/html/version.json
업로드 디렉토리: /home/update/html/pkgs/
```
## 🔑 인증 정보
### 관리자 계정
- **ID**: admin
- **Password**: (별도 관리)
### API 키
```
ot_secret_key_2026_v7
```
## 📤 배포 방법
### 1. 전체 배포 (초기 설정)
```bash
# SCP로 전체 파일 업로드
scp -r php-portal/* update@update.onetake.best:/home/update/html/
# SSH 접속 후 권한 설정
ssh update@update.onetake.best
chmod 755 /home/update/html
chmod 666 /home/update/html/version.json
chmod 755 /home/update/html/pkgs
```
### 2. 포털 업데이트 (운영 중)
```bash
# 방법 1: API를 통한 업데이트
curl -F "api_key=ot_secret_key_2026_v7" \
-F "id=portal-update" \
-F "parent=SYSTEM" \
-F "name=Portal Update" \
-F "versionName=v8.10" \
-F "versionCode=810" \
-F "file=@php-portal/index.php" \
"https://update.onetake.best/index.php?action=publish_update"
# 방법 2: 직접 SCP
scp php-portal/index.php update@update.onetake.best:/home/update/html/
```
### 3. Swap 스크립트 사용
```bash
# 1. swap_root.php 업로드
curl -F "api_key=ot_secret_key_2026_v7" \
-F "id=portal-update" \
-F "versionName=v8.10" \
-F "file=@swap_root.php" \
"https://update.onetake.best/index.php?action=publish_update"
# 2. 스크립트 실행으로 배포
curl "https://update.onetake.best/pkgs/portal-update/v8.10/swap_root.php"
```
## 🔧 서버 설정
### PHP 설정 (php.ini)
```ini
upload_max_filesize = 512M
post_max_size = 512M
max_execution_time = 300
memory_limit = 512M
```
### Apache/Nginx 설정
```nginx
# Nginx 예시
client_max_body_size 512M;
```
## 📊 디렉토리 구조 (서버)
```
/home/update/html/
├── index.php # 메인 포털
├── version.json # 버전 데이터
├── pkgs/ # 업로드된 패키지
│ ├── cm-tv/
│ │ └── v2.24.1/
│ │ └── tv-app.apk
│ ├── cm-controller/
│ │ └── v2.26.0/
│ │ └── controller-app.apk
│ ├── cm-sender/
│ │ └── v1.2.0/
│ │ └── PCSenderApp.exe
│ ├── cm-server/
│ │ └── v2.24.1/
│ │ └── server-package.zip
│ └── portal-update/
│ └── v8.10/
│ ├── index.php
│ └── swap_root.php
├── private_key.pem # JWT 서명 키
└── public_key.pem # JWT 검증 키
```
## 🛡️ 보안 체크리스트
- [ ] SSH 키 기반 인증 설정
- [ ] 방화벽 설정 (포트 22, 80, 443만 허용)
- [ ] HTTPS 인증서 설정
- [ ] 관리자 비밀번호 강화
- [ ] API 키 주기적 변경
- [ ] 업로드 디렉토리 실행 권한 제거
- [ ] version.json 백업 설정
## 🔄 백업 및 복구
### 백업
```bash
# SSH 접속 후
cd /home/update/html
tar -czf backup-$(date +%Y%m%d).tar.gz \
index.php version.json pkgs/ *.pem
# 로컬로 다운로드
scp update@update.onetake.best:/home/update/html/backup-*.tar.gz ./
```
### 복구
```bash
# 백업 파일 업로드
scp backup-20260206.tar.gz update@update.onetake.best:/home/update/html/
# SSH 접속 후 압축 해제
ssh update@update.onetake.best
cd /home/update/html
tar -xzf backup-20260206.tar.gz
```
## 📝 로그 확인
### Apache 로그
```bash
tail -f /var/log/apache2/access.log
tail -f /var/log/apache2/error.log
```
### PHP 에러 로그
```bash
tail -f /var/log/php/error.log
```
## 🚨 트러블슈팅
### 업로드 실패
1. 파일 크기 제한 확인 (php.ini)
2. 디렉토리 권한 확인 (755/666)
3. 디스크 공간 확인 (`df -h`)
### 포털 접속 불가
1. Apache/Nginx 상태 확인 (`systemctl status apache2`)
2. PHP-FPM 상태 확인 (`systemctl status php-fpm`)
3. 방화벽 설정 확인 (`ufw status`)
### version.json 손상
1. 백업에서 복구
2. 또는 빈 구조로 초기화:
```json
{
"categories": ["GENERAL", "SYSTEM"],
"projects": []
}
```
## 📞 연락처
서버 관리: update@onetake.best

36
db_setup.php Normal file
View File

@ -0,0 +1,36 @@
<?php
// 임시 DB 설치 스크립트
$config = [
'db_host' => 'update.onetake.best',
'db_user' => 'onetake',
'db_pw' => 'dnjsxpdlzm1!',
'db_name' => 'onetake'
];
try {
$dsn = "mysql:host={$config['db_host']};dbname={$config['db_name']};charset=utf8mb4";
$db = new PDO($dsn, $config['db_user'], $config['db_pw'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
$sql = "CREATE TABLE IF NOT EXISTS `licenses` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`project_name` VARCHAR(100) NOT NULL,
`device_id` VARCHAR(100) NOT NULL DEFAULT '*',
`expiry_date` DATE NOT NULL,
`tv_limit` INT NOT NULL DEFAULT 1,
`token` TEXT NOT NULL,
`issued_by` VARCHAR(50) NOT NULL,
`issued_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_project` (`project_name`),
INDEX `idx_device` (`device_id`),
INDEX `idx_issued_at` (`issued_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
$db->exec($sql);
echo "<h1>SUCCESS</h1><p>'licenses' table created or already exists.</p>";
} catch (PDOException $e) {
echo "<h1>ERROR</h1><p>" . $e->getMessage() . "</p>";
}
?>

21
db_test_remote.php Normal file
View File

@ -0,0 +1,21 @@
<?php
$options = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION];
$creds = [
['onetake', 'dnjsxpdlzm1!'],
['update', 'asdf0901!'],
['root', ''],
['eventwork', 'eventwork!@#']
];
foreach ($creds as $c) {
echo "Testing {$c[0]}... ";
try {
$dsn = "mysql:host=192.168.100.221;charset=utf8mb4";
$db = new PDO($dsn, $c[0], $c[1], $options);
echo "SUCCESS\n";
exit(0);
} catch (Exception $e) {
echo "FAILED: " . $e->getMessage() . "\n";
}
}
exit(1);
?>

138
php-portal/README.md Normal file
View File

@ -0,0 +1,138 @@
# PHP Portal - 업데이트 관리 시스템
## 📋 개요
범용 프로젝트 업데이트 파일을 관리하는 웹 기반 포털입니다.
## 🔑 주요 기능
### 1. 프로젝트 관리
- 프로젝트 목록 조회 및 편집
- 카테고리별 그룹화
- 버전 정보 관리
- 파일 업로드 (드래그 앤 드롭 지원)
### 2. 업데이트 API
- `publish_update` - 새 버전 업로드
- `check_update` - 버전 확인 (Public)
- `version` - 전체 프로젝트 정보
### 3. 라이선스 생성
- JWT 기반 라이선스 토큰 생성
- QR 코드 자동 생성
- 카카오톡 공유 기능
## 🚀 배포 방법
### 서버 요구사항
- PHP 7.4+
- Apache/Nginx
- 쓰기 권한 (pkgs/, version.json)
### 초기 설정
```bash
# 1. 파일 업로드
scp -r php-portal/* user@update.onetake.best:/home/update/html/
# 2. 권한 설정
chmod 755 /home/update/html
chmod 666 /home/update/html/version.json
chmod 755 /home/update/html/pkgs
```
### 설정 파일 (index.php 상단)
```php
$config = [
'admin_id' => 'admin',
'admin_pw' => password_hash('your_password', PASSWORD_DEFAULT),
'api_key' => 'ot_secret_key_2026_v7',
'version_file' => 'version.json',
'upload_dir' => 'pkgs/'
];
```
## 📡 API 사용법
### 업데이트 발행
```bash
curl -F "api_key=ot_secret_key_2026_v7" \
-F "id=cm-tv" \
-F "parent=MYPROJECT" \
-F "name=TV Display App" \
-F "versionName=v2.24.1" \
-F "versionCode=2241" \
-F "description=Update description" \
-F "file=@tv-app.apk" \
"https://update.onetake.best/index.php?action=publish_update"
```
### 버전 확인
```bash
curl "https://update.onetake.best/index.php?action=check_update&id=cm-tv"
```
## 🎨 UI 기능
### 파일 타입 자동 인식
- **ANDROID** (파란색) - .apk 파일
- **WINDOWS** (청록색) - .exe 파일
- **ARCHIVE** (보라색) - .zip 파일
### 빌드 타입 배지
- **DEBUG** (주황색) - 디버그 빌드
- **RELEASE** (녹색) - 릴리스 빌드
- **UNSIGNED** (빨간색) - 서명되지 않은 빌드
## 🔒 보안
### 인증
- 관리자 로그인 (ID/PW)
- API 키 인증
- 세션 기반 인증
### 파일 업로드
- 파일 크기 제한 체크
- 안전한 파일명 생성
- 디렉토리 자동 생성
## 📁 디렉토리 구조
```
php-portal/
├── index.php # 메인 포털 파일
├── version.json # 프로젝트 버전 데이터
├── pkgs/ # 업로드된 패키지 파일
│ ├── cm-tv/
│ ├── cm-controller/
│ ├── cm-sender/
│ └── cm-server/
├── assets/ # CSS, JS 리소스
├── private_key.pem # JWT 서명 키
└── public_key.pem # JWT 검증 키
```
## 🔧 유지보수
### 버전 업데이트
```bash
# 포털 자체 업데이트
curl -F "api_key=ot_secret_key_2026_v7" \
-F "id=portal-update" \
-F "parent=SYSTEM" \
-F "file=@index.php" \
"https://update.onetake.best/index.php?action=publish_update"
# 배포 스크립트 실행
curl "https://update.onetake.best/pkgs/portal-update/v8.10/swap_root.php"
```
### 백업
```bash
# version.json 백업
cp version.json version.json.backup
# 전체 백업
tar -czf portal-backup-$(date +%Y%m%d).tar.gz php-portal/
```
## 📊 현재 버전
- Portal: v8.10
- Last Updated: 2026-02-06

57
php-portal/db_setup.php Normal file
View File

@ -0,0 +1,57 @@
<?php
// 상용 서비스를 위한 DB 확장 설치 스크립트
$config = [
'db_host' => '192.168.100.221',
'db_user' => 'update',
'db_pw' => 'asdf0901!',
'db_name' => 'onetake-update'
];
try {
$dsn = "mysql:host={$config['db_host']};dbname={$config['db_name']};charset=utf8mb4";
$db = new PDO($dsn, $config['db_user'], $config['db_pw'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
// 1. 사용자 테이블 (일반 구매자)
$db->exec("CREATE TABLE IF NOT EXISTS `users` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`email` VARCHAR(100) NOT NULL UNIQUE,
`password` VARCHAR(255) NOT NULL,
`name` VARCHAR(50) NOT NULL,
`confirm_pw` VARCHAR(255) COMMENT '결제 확인용 비밀번호(필요시)',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
// 2. 결제 내역 테이블
$db->exec("CREATE TABLE IF NOT EXISTS `orders` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`order_id` VARCHAR(50) NOT NULL UNIQUE COMMENT '자체 주문번호',
`user_id` INT NOT NULL,
`amount` INT NOT NULL,
`method` VARCHAR(20) COMMENT 'TOSS, NAVER 등',
`payment_key` VARCHAR(255) COMMENT '결제사 승인 키',
`status` ENUM('READY', 'DONE', 'CANCELED') DEFAULT 'READY',
`item_name` VARCHAR(100),
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_user` (`user_id`),
INDEX `idx_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
// 3. 기존 licenses 테이블 확장 (user_id, order_id 연결)
// 컬럼 존재 여부 체크 후 추가
$columns = $db->query("SHOW COLUMNS FROM `licenses` LIKE 'user_id'")->fetch();
if (!$columns) {
$db->exec("ALTER TABLE `licenses`
ADD COLUMN `user_id` INT NULL AFTER `id`,
ADD COLUMN `order_id` INT NULL AFTER `user_id`,
ADD INDEX `idx_user_license` (`user_id`);");
}
echo "<h1>✅ DB REFACTORED</h1><p>Users, Orders tables created and Licenses updated.</p>";
} catch (PDOException $e) {
echo "<h1>❌ ERROR</h1><p>" . $e->getMessage() . "</p>";
}
?>

14
php-portal/fix_perms.php Normal file
View File

@ -0,0 +1,14 @@
<?php
$dir = 'pkgs';
if (!is_dir($dir)) {
if (mkdir($dir, 0777, true)) {
echo "Successfully created '$dir' folder.\n";
} else {
echo "Failed to create '$dir' folder.\n";
}
}
chmod($dir, 0777);
// 덤으로 현재 폴더 내의 파일들이 쓸 수 있는지 체크
echo "Current directory: " . getcwd() . "\n";
echo "WWW user: " . exec('whoami') . "\n";
?>

1617
php-portal/index.php Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC+xxAsVUwQDsa3
p3rTF9pfN5u+g+yUOygC5zR8L8PccovsLe9tL2S/VRXRjhxATAsmg2OcLqNsRy5f
31GF/Av9lGCo+c7qA5ssS23rDTaITL20MID9ezl3+jo7Wru2udpOxva6JcWU9Qez
Hho8RZ9BM911efnp7ezdPeJRu1dmaBBCTTSXoiS8avS85zojV5fJrL8+Rf/r5gjb
Km1JfjBA3zyfmAkgEN7Gr9ZqoMBz9dWTtvS2zy4tiPTn1QJ8wmGo5tjkEzeL/NGz
JBS0HtZNZcTy+ZKLQYqAvKcSeZFRUByqorO1cZHqLMPXzxXjc75xF8gdkRgEJahX
/fG7/6fTAgMBAAECggEADnP8SK+JnKnR6jX4+ycSdyY6WVubP7ufk2C6vDHOL9RJ
v3HXcrklc81ZYs/WWhSov5kyobFy1hAqdj7v6SuoKOTl0cdjIp11Uwy/3g/ZMshF
kvIdw5ZjSzCc2sRL9lLsNA2kwYN9DYTmuW4tZKWNpB3uyCieg1dwG27Fx5Ve0LYt
1dJtVzIg5CR8loERtA+X7GtkXhEWEYfCLpLKApTgnw+Hqw9FB1e+mPFeNhZhTp5P
1iehHcdNzLeQ2sWfVGM3UL9S7eWGG6mf4rG7Gr8r9ovDGQvfctmmznhKqyuJJ1NS
AI3uejClp1qixlx+zEMMIJ4sNTH37u+MbLEH44MHMQKBgQD4hRAbdJkXIrpcyOPd
rSG2Ays5FVBusIigYx94fGcnujn9VI5rUbtaT9Rk2z9OK67vWnqqeGzIhtPKHARz
nj/RpzBBeNa3ssBoRhqYD5diqe9OBgY3yb+tK9c/mVod3dRwYwqXVNWS+rFyeF3G
yAZG7eg1svFgiqkGiENdhu9mWQKBgQDEhRNGHg+zMzgBp07jIddsUacSR2iKjHqo
cFyZQumZALvlfsGiAJ4eSWHa4CVovl8BoULtabm2IwCSQvKIEAgsFWBl9elvPIEq
dB7lIc7TtgnyebakS8ncgBle1rIFoixAk0n+kaMrvequzSZ8Y+DxeKbkle0NLHtF
3a2opiQSCwKBgQC+uZ/68ijrUIOl4aa+4bgVb6kkTe9Eg8bXEDt+xDqGiq0mdlY2
lqsqTEm8fWbAH1ZJ6y5o1bLm0lKHsajY8oIX7C9kj9B5en2fiO2v9YdA+Rnmz0jG
V9b6l8LB1HcMpMn81oWyTjD6c9rq9uVBQRFQLhUf4QzOApxlnv3UMVJZQQKBgQC9
SYSwFAKBVANNLG8CvbT2w4tOQvPCB9+ZYGkAwn+ofRl+yuINfdTPTVVw8ld2FXAD
bOW/MgfMFNjXCJ79SZvlgk2QyBWprDipwKGFiFPkfkIEiRHQHKP5vHUzcU6VuIgx
Ru2Nw4/McSicaEP6qmWfkNwH7xUejErMl+JOQIEb0wKBgQDnh80j9pIAnNmwjYaD
TgHCvWAfqgvt2yRDIV+rR1FMKmM3W7b4FdevV8r6tQW1GKDXZ0ByX/tNrjnLl3aQ
Xtg2wjabyirWbMek6V+oCEDKY4oivqgXysFjMnYAXlr3ANAnbEE9Y4P16kmoZurI
/Pf5MdXG6QpbQaoANQa4tP4JYw==
-----END PRIVATE KEY-----

View File

@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvscQLFVMEA7Gt6d60xfa
XzebvoPslDsoAuc0fC/D3HKL7C3vbS9kv1UV0Y4cQEwLJoNjnC6jbEcuX99RhfwL
/ZRgqPnO6gObLEtt6w02iEy9tDCA/Xs5d/o6O1q7trnaTsb2uiXFlPUHsx4aPEWf
QTPddXn56e3s3T3iUbtXZmgQQk00l6IkvGr0vOc6I1eXyay/PkX/6+YI2yptSX4w
QN88n5gJIBDexq/WaqDAc/XVk7b0ts8uLYj059UCfMJhqObY5BM3i/zRsyQUtB7W
TWXE8vmSi0GKgLynEnmRUVAcqqKztXGR6izD188V43O+cRfIHZEYBCWoV/3xu/+n
0wIDAQAB
-----END PUBLIC KEY-----

558
php-portal/shop.php Normal file
View File

@ -0,0 +1,558 @@
<?php
header('Content-Type: text/html; charset=utf-8');
date_default_timezone_set('Asia/Seoul');
session_start();
// 로그아웃 처리
if (isset($_GET['logout'])) {
session_destroy();
header('Location: shop.php');
exit;
}
$config = [
'db_host' => '192.168.100.221',
'db_user' => 'update',
'db_pw' => 'asdf0901!',
'db_name' => 'onetake-update',
'toss_client_key' => 'test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm', // 결제위젯 전용 테스트 키
'toss_secret_key' => 'test_gsk_docs_Ovk5rk1EwkEbP0W43n07xlzm', // 결제위젯 전용 테스트 키
];
// DB 연결 함수
function get_db()
{
global $config;
try {
$dsn = "mysql:host={$config['db_host']};dbname={$config['db_name']};charset=utf8mb4";
return new PDO($dsn, $config['db_user'], $config['db_pw'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
} catch (PDOException $e) {
return null;
}
}
// 1. 회원가입/로그인 처리
if (isset($_GET['action'])) {
$db = get_db();
header('Content-Type: application/json');
if ($_GET['action'] == 'auth') {
$email = $_POST['email'];
$pw = $_POST['pw'];
$type = $_POST['type']; // login or join
if ($type == 'join') {
$name = $_POST['name'];
$hashed = password_hash($pw, PASSWORD_DEFAULT);
try {
$stmt = $db->prepare("INSERT INTO users (email, password, name) VALUES (?, ?, ?)");
$stmt->execute([$email, $hashed, $name]);
echo json_encode(['success' => true, 'msg' => '가입 완료! 로그인 해주세요.']);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => '이미 존재하는 이메일입니다.']);
}
} else {
$stmt = $db->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();
if ($user && password_verify($pw, $user['password'])) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_email'] = $user['email'];
$_SESSION['user_name'] = $user['name'];
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => '아이디 또는 비번이 틀립니다.']);
}
}
exit;
}
// 2. 주문 생성 (결제 전 READY 상태)
if ($_GET['action'] == 'create_order' && isset($_SESSION['user_id'])) {
$amount = (int) $_POST['amount'];
$itemName = $_POST['itemName'];
$orderId = "OT-" . time() . "-" . $_SESSION['user_id'];
$stmt = $db->prepare("INSERT INTO orders (order_id, user_id, amount, item_name, status) VALUES (?, ?, ?, ?, 'READY')");
$stmt->execute([$orderId, $_SESSION['user_id'], $amount, $itemName]);
echo json_encode(['success' => true, 'orderId' => $orderId]);
exit;
}
// 3. 내 QR 목록 가져오기
if ($_GET['action'] == 'my_qrs' && isset($_SESSION['user_id'])) {
$stmt = $db->prepare("SELECT * FROM licenses WHERE user_id = ? ORDER BY issued_at DESC");
$stmt->execute([$_SESSION['user_id']]);
echo json_encode(['success' => true, 'list' => $stmt->fetchAll()]);
exit;
}
// 4. 결제 성공 처리 (토스 승인 API)
if ($_GET['action'] == 'payment_success' && isset($_SESSION['user_id'])) {
$paymentKey = $_GET['paymentKey'];
$orderId = $_GET['orderId'];
$amount = $_GET['amount'];
// 1) 토스에 승인 요청 (Secret Key 인증)
$credential = base64_encode($config['toss_secret_key'] . ":");
$ch = curl_init("https://api.tosspayments.com/v1/payments/confirm");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Basic $credential", "Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['paymentKey' => $paymentKey, 'orderId' => $orderId, 'amount' => $amount]));
$response = curl_exec($ch);
$resData = json_decode($response, true);
curl_close($ch);
if (isset($resData['status']) && $resData['status'] === 'DONE') {
// 2) 주문 상태 변경
$stmt = $db->prepare("UPDATE orders SET status = 'DONE', payment_key = ?, method = ? WHERE order_id = ?");
$stmt->execute([$paymentKey, $resData['method'], $orderId]);
// 3) 라이선스 자동 생성 (1년 만료 기준)
$pData = $db->prepare("SELECT * FROM orders WHERE order_id = ?");
$pData->execute([$orderId]);
$order = $pData->fetch();
$expiry = date('Y-m-d', strtotime('+1 year'));
$private_key = @file_get_contents('private_key.pem');
$licenseData = json_encode([
'expiry' => $expiry,
'deviceId' => '*',
'projectName' => $order['item_name'],
'tvLimit' => (strpos($order['item_name'], 'Premium') !== false) ? 3 : 1,
'issuedAt' => time() * 1000,
'type' => 'commercial'
]);
openssl_sign($licenseData, $signature, $private_key, OPENSSL_ALGO_SHA256);
$finalToken = base64_encode(json_encode([
'd' => $licenseData,
's' => base64_encode($signature)
]));
// 4) DB에 라이선스 등록
$stmt = $db->prepare("INSERT INTO licenses (user_id, project_name, device_id, expiry_date, tv_limit, token, issued_by, issued_at) VALUES (?, ?, '*', ?, ?, ?, 'AUTO_PAYMENT', NOW())");
$stmt->execute([
$_SESSION['user_id'],
$order['item_name'],
$expiry,
(strpos($order['item_name'], 'Premium') !== false) ? 3 : 1,
$finalToken
]);
echo "<html><script>alert('구매 성공! QR함에서 확인하세요.'); location.href='shop.php';</script></html>";
} else {
echo "<html><script>alert('결제 승인 실패: " . ($resData['message'] ?? 'Unknown') . "'); location.href='shop.php';</script></html>";
}
exit;
}
}
$is_logged_in = isset($_SESSION['user_id']);
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OneTake 라이선스 스토어</title>
<script src="https://js.tosspayments.com/v1/payment-widget"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.4.4/build/qrcode.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--primary: #3182f6;
/* 토스 블루 */
--bg: #f2f4f6;
--text: #191f28;
--card: #ffffff;
}
body {
font-family: 'Outfit', 'Pretendard', sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding: 0;
}
.container {
max-width: 500px;
margin: 0 auto;
padding: 20px;
}
/* Glassmorphism Header */
header {
position: sticky;
top: 0;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 100;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.logo {
font-weight: 700;
font-size: 20px;
color: var(--primary);
letter-spacing: -1px;
}
.card {
background: var(--card);
border-radius: 24px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
}
h2 {
margin-top: 0;
font-weight: 700;
font-size: 24px;
}
input {
width: 100%;
padding: 15px;
margin: 8px 0;
border: 1px solid #e5e8eb;
border-radius: 12px;
box-sizing: border-box;
font-size: 16px;
transition: 0.2s;
}
input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.1);
}
button {
width: 100%;
padding: 16px;
background: var(--primary);
color: #fff;
border: none;
border-radius: 16px;
font-weight: 600;
font-size: 16px;
cursor: pointer;
transition: 0.2s;
}
button:hover {
filter: brightness(1.1);
transform: translateY(-2px);
}
button.secondary {
background: #e8f3ff;
color: var(--primary);
}
.product {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border: 2px solid #f2f4f6;
border-radius: 16px;
margin-bottom: 12px;
cursor: pointer;
transition: 0.2s;
}
.product:hover {
border-color: var(--primary);
background: rgba(49, 130, 246, 0.02);
}
.product.active {
border-color: var(--primary);
background: rgba(49, 130, 246, 0.05);
}
.product-info b {
font-size: 18px;
display: block;
}
.product-info span {
font-size: 13px;
color: #8b95a1;
}
.price {
font-weight: 700;
color: var(--primary);
font-size: 18px;
}
.qr-item {
display: flex;
gap: 15px;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #f2f4f6;
}
.qr-canvas {
width: 80px;
height: 80px;
background: #eee;
border-radius: 8px;
}
.qr-info {
flex: 1;
}
.qr-info b {
display: block;
font-size: 15px;
}
.qr-info span {
font-size: 12px;
color: #8b95a1;
}
#toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
background: #191f28;
color: #fff;
padding: 12px 24px;
border-radius: 50px;
font-size: 14px;
opacity: 0;
transition: 0.3s;
z-index: 9999;
}
</style>
</head>
<body>
<header>
<div class="logo">OneTake Store</div>
<?php if ($is_logged_in): ?>
<a href="?logout=1" style="font-size:14px; color:#8b95a1; text-decoration:none;">로그아웃</a>
<?php endif; ?>
</header>
<div class="container">
<?php if (!$is_logged_in): ?>
<!-- 로그인/회원가입 섹션 -->
<div class="card" id="auth-section">
<h2 id="auth-title">로그인</h2>
<p style="color:#8b95a1; font-size:14px; margin-top:-10px;">라이선스 구매 및 관리를 위해 접속하세요.</p>
<div id="join-fields" style="display:none;">
<input type="text" id="user-name" placeholder="이름">
</div>
<input type="email" id="user-email" placeholder="이메일 주소">
<input type="password" id="user-pw" placeholder="비밀번호">
<button onclick="handleAuth()" id="auth-btn" style="margin-top:20px;">시작하기</button>
<p style="text-align:center; font-size:14px; margin-top:20px;">
<span id="auth-msg">계정이 없으신가요?</span>
<a href="javascript:toggleAuth()" id="auth-toggle"
style="color:var(--primary); text-decoration:none; font-weight:600;">회원가입</a>
</p>
</div>
<?php else: ?>
<!-- 스토어 섹션 -->
<div class="card">
<h2>🎉 반갑습니다,
<?= $_SESSION['user_name'] ?>님!
</h2>
<div style="display:flex; gap:10px; margin-top:10px;">
<button onclick="tab('buy')" class="secondary">라이선스 구매</button>
<button onclick="tab('my')" class="secondary">내 QR 보관함</button>
</div>
</div>
<!-- 구매 탭 -->
<div id="buy-sec" class="tab-sec">
<div class="card">
<h3>제품 선택</h3>
<div class="product active" onclick="selectProduct(33000, 'OneTake Standard (1년)')">
<div class="product-info">
<b>Standard 라이선스</b>
<span>1년 / 1대 기기 제한</span>
</div>
<div class="price">₩33,000</div>
</div>
<div class="product" onclick="selectProduct(55000, 'OneTake Premium (1년)')">
<div class="product-info">
<b>Premium 라이선스</b>
<span>1년 / 3대 기기 가능</span>
</div>
<div class="price">₩55,000</div>
</div>
<div class="product" onclick="selectProduct(99000, 'OneTake Life-time')">
<div class="product-info">
<b>평생 소장용</b>
<span>무제한 업데이트 / 1대 기기</span>
</div>
<div class="price">₩99,000</div>
</div>
<div id="payment-widget" style="margin-top:20px;"></div>
<button onclick="requestPayment()" style="margin-top:20px; background:#191f28;">결제하기</button>
</div>
</div>
<!-- 마이페이지 탭 -->
<div id="my-sec" class="tab-sec" style="display:none;">
<div class="card">
<h3>내 라이선스 QR</h3>
<div id="my-qr-list">
<p style="text-align:center; padding:20px; opacity:0.5;">불러오는 중...</p>
</div>
</div>
</div>
<?php endif; ?>
</div>
<div id="toast">메시지</div>
<script>
let currentAmount = 33000;
let currentItem = "OneTake Standard (1년)";
let isJoin = false;
let paymentWidget = null;
<?php if ($is_logged_in): ?>
// 토스 결제 위젯 초기화 (고객 키는 최소 2자 이상 사용 필수)
const customerKey = "USER_" + "<?= $_SESSION['user_id'] ?>";
paymentWidget = PaymentWidget("<?= $config['toss_client_key'] ?>", customerKey);
// 결제 UI 렌더링
paymentWidget.renderPaymentMethods("#payment-widget", { value: currentAmount });
loadMyQRs();
<?php endif; ?>
function toggleAuth() {
isJoin = !isJoin;
document.getElementById('auth-title').innerText = isJoin ? '회원가입' : '로그인';
document.getElementById('join-fields').style.display = isJoin ? 'block' : 'none';
document.getElementById('auth-msg').innerText = isJoin ? '이미 계정이 있나요?' : '계정이 없으신가요?';
document.getElementById('auth-toggle').innerText = isJoin ? '로그인' : '회원가입';
document.getElementById('auth-btn').innerText = isJoin ? '가입하기' : '시작하기';
}
async function handleAuth() {
const email = document.getElementById('user-email').value;
const pw = document.getElementById('user-pw').value;
const name = document.getElementById('user-name').value;
if (!email || !pw) return showToast("이메일과 비번을 입력하세요.");
const fd = new FormData();
fd.append('email', email);
fd.append('pw', pw);
fd.append('name', name);
fd.append('type', isJoin ? 'join' : 'login');
const res = await fetch('?action=auth', { method: 'POST', body: fd });
const data = await res.json();
if (data.success) {
if (isJoin) {
showToast("가입되었습니다! 로그인 해주세요.");
toggleAuth();
} else {
location.reload();
}
} else {
showToast(data.error);
}
}
function selectProduct(amount, name) {
currentAmount = amount;
currentItem = name;
document.querySelectorAll('.product').forEach(p => p.classList.remove('active'));
event.currentTarget.classList.add('active');
if (paymentWidget) paymentWidget.updateAmount(amount);
}
async function requestPayment() {
const fd = new FormData();
fd.append('amount', currentAmount);
fd.append('itemName', currentItem);
const res = await fetch('?action=create_order', { method: 'POST', body: fd });
const data = await res.json();
if (data.success) {
if (!paymentWidget) {
alert("결제 위젯이 아직 로드되지 않았습니다. 1~2초 후 다시 시도해주시거나 새로고침(F5) 해주세요.");
return;
}
paymentWidget.requestPayment({
orderId: data.orderId,
orderName: currentItem,
successUrl: window.location.origin + window.location.pathname + "?action=payment_success",
failUrl: window.location.origin + window.location.pathname + "?action=payment_fail",
});
}
}
async function loadMyQRs() {
const res = await fetch('?action=my_qrs');
const data = await res.json();
const listDiv = document.getElementById('my-qr-list');
if (data.success && data.list.length > 0) {
listDiv.innerHTML = data.list.map(q => `
<div class="qr-item">
<canvas id="qr-${q.id}" class="qr-canvas"></canvas>
<div class="qr-info">
<b>${q.project_name}</b>
<span>만료일: ${q.expiry_date}</span>
<div style="font-size:10px; opacity:0.3; margin-top:5px; word-break:break-all;">${q.token.substring(0, 30)}...</div>
</div>
</div>
`).join('');
data.list.forEach(q => {
QRCode.toCanvas(document.getElementById('qr-' + q.id), q.token, { width: 80, margin: 1 });
});
} else {
listDiv.innerHTML = '<p style="text-align:center; padding:40px; opacity:0.5;">구매한 내역이 없습니다.</p>';
}
}
function tab(id) {
document.querySelectorAll('.tab-sec').forEach(s => s.style.display = 'none');
document.getElementById(id + '-sec').style.display = 'block';
if (id === 'my') loadMyQRs();
}
function showToast(msg) {
const t = document.getElementById('toast');
t.innerText = msg;
t.style.opacity = '1';
setTimeout(() => t.style.opacity = '0', 2000);
}
</script>
</body>
</html>

36
php-portal/swap_root.php Normal file
View File

@ -0,0 +1,36 @@
<?php
/**
* Swap Root Script
* 이 파일은 pkgs/portal-update/vX.X/ 폴더에 위치하며,
* 같은 폴더에 있는 index.php를 웹 루트(/)로 복사합니다.
*/
$target = "../../../index.php"; // 루트의 index.php
$source = "index.php"; // 현재 폴더의 새 index.php
if (!file_exists($source)) {
die("Error: Source index.php not found in current directory.");
}
if (copy($source, $target)) {
@copy("shop.php", "../../../shop.php");
@copy("private_key.pem", "../../../private_key.pem");
@copy("public_key.pem", "../../../public_key.pem");
// 권한 설정 (Forbidden 에러 방지)
@chmod($target, 0644);
@chmod("../../../shop.php", 0644);
@chmod("../../../private_key.pem", 0644);
@chmod("../../../public_key.pem", 0644);
echo "<h1>✅ SUCCESS</h1>";
echo "<p>Portal index.php has been updated to the root directory.</p>";
echo "<p>Source: " . realpath($source) . "</p>";
echo "<p>Target: " . realpath($target) . "</p>";
} else {
echo "<h1>❌ ERROR</h1>";
echo "<p>Failed to copy index.php to root. Check permissions.</p>";
$error = error_get_last();
echo "<p>Error: " . $error['message'] . "</p>";
}
?>

View File

@ -0,0 +1,42 @@
{
"projects": [
{
"id": "controller-app",
"name": "Controller App",
"versionName": "v2.24.7",
"versionCode": 247,
"date": "2026-02-05",
"description": "최신 보안 업데이트 적용",
"apkUrl": "https://update.onetake.best/updates/controller-app_247.apk"
},
{
"id": "tv-app",
"name": "TV App",
"versionName": "v2.24.7",
"versionCode": 247,
"date": "2026-02-05",
"description": "최신 보안 업데이트 적용",
"apkUrl": "https://update.onetake.best/updates/tv-app_247.apk"
},
{
"id": "pc-sender-agent",
"name": "PC Sender Agent (Capture)",
"versionName": "v1.0.0",
"versionCode": 100,
"date": "2026-02-05",
"description": "PC 화면 캡처 및 키 전송 에이전트",
"downloadUrl": "https://update.onetake.best/pkgs/pc-sender-agent_100.zip",
"apkUrl": "https://update.onetake.best/pkgs/pc-sender-agent_100.zip"
},
{
"id": "node-server",
"name": "Node.js Backend Server",
"versionName": "v2.24.7",
"versionCode": 247,
"date": "2026-02-05",
"description": "신규 관리자 포털이 포함된 서버 백엔드 전체 패키지",
"downloadUrl": "https://update.onetake.best/pkgs/node-server_247.zip",
"apkUrl": "https://update.onetake.best/pkgs/node-server_247.zip"
}
]
}

14
schema.sql Normal file
View File

@ -0,0 +1,14 @@
-- QR 라이선스 발행 기록 테이블 생성
CREATE TABLE IF NOT EXISTS `licenses` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`project_name` VARCHAR(100) NOT NULL COMMENT '프로젝트 대분류',
`device_id` VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '허용 디바이스 ID',
`expiry_date` DATE NOT NULL COMMENT '만료일',
`tv_limit` INT NOT NULL DEFAULT 1 COMMENT 'TV 연결 제한 댓수',
`token` TEXT NOT NULL COMMENT '생성된 JWT 토큰',
`issued_by` VARCHAR(50) NOT NULL COMMENT '발행한 관리자 ID',
`issued_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '발행 일시',
INDEX `idx_project` (`project_name`),
INDEX `idx_device` (`device_id`),
INDEX `idx_issued_at` (`issued_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;