Initial commit: FreeBSD deployment complete
This commit is contained in:
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal 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
272
LICENSE_SYSTEM.md
Normal 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
119
README.md
Normal 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
188
SERVER_INFO.md
Normal 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
36
db_setup.php
Normal 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
21
db_test_remote.php
Normal 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
138
php-portal/README.md
Normal 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
57
php-portal/db_setup.php
Normal 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
14
php-portal/fix_perms.php
Normal 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
1617
php-portal/index.php
Normal file
File diff suppressed because it is too large
Load Diff
28
php-portal/private_key.pem
Normal file
28
php-portal/private_key.pem
Normal 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-----
|
||||
9
php-portal/public_key.pem
Normal file
9
php-portal/public_key.pem
Normal 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
558
php-portal/shop.php
Normal 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
36
php-portal/swap_root.php
Normal 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>";
|
||||
}
|
||||
?>
|
||||
42
php-portal/updates/version.json
Normal file
42
php-portal/updates/version.json
Normal 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
14
schema.sql
Normal 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;
|
||||
Reference in New Issue
Block a user