pxa-drm-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pxa_drm_server/__init__.py +26 -0
- pxa_drm_server/__main__.py +7 -0
- pxa_drm_server/api/__init__.py +1 -0
- pxa_drm_server/api/v1/__init__.py +4 -0
- pxa_drm_server/api/v1/routes.py +83 -0
- pxa_drm_server/app.py +80 -0
- pxa_drm_server/asgi.py +12 -0
- pxa_drm_server/codes.py +46 -0
- pxa_drm_server/config.py +101 -0
- pxa_drm_server/domain.py +122 -0
- pxa_drm_server/errors.py +94 -0
- pxa_drm_server/main.py +112 -0
- pxa_drm_server/messages/drm.yaml +25 -0
- pxa_drm_server/runtime.py +49 -0
- pxa_drm_server/service.py +169 -0
- pxa_drm_server/sources/__init__.py +14 -0
- pxa_drm_server/sources/base.py +43 -0
- pxa_drm_server/sources/local.py +84 -0
- pxa_drm_server/sources/objectstorage.py +163 -0
- pxa_drm_server/sources/upload.py +79 -0
- pxa_drm_server/templates/__init__.py +8 -0
- pxa_drm_server/templates/config.yaml +55 -0
- pxa_drm_server/templates/domain_core.py +74 -0
- pxa_drm_server/workspace.py +37 -0
- pxa_drm_server-0.1.0.dist-info/METADATA +311 -0
- pxa_drm_server-0.1.0.dist-info/RECORD +30 -0
- pxa_drm_server-0.1.0.dist-info/WHEEL +5 -0
- pxa_drm_server-0.1.0.dist-info/entry_points.txt +2 -0
- pxa_drm_server-0.1.0.dist-info/licenses/LICENSE +201 -0
- pxa_drm_server-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""pxa-drm-server — 파일 암복호화 API 서버.
|
|
2
|
+
|
|
3
|
+
개발자는 ``domain_core.py`` 에 세 함수만 구현하면 된다::
|
|
4
|
+
|
|
5
|
+
def encrypt(filepath: str) -> bool: ...
|
|
6
|
+
def decrypt(filepath: str) -> bool: ...
|
|
7
|
+
def check(filepath: str) -> bool: ...
|
|
8
|
+
|
|
9
|
+
REST API / 로깅 / 에러처리 / 표준 응답 / 오브젝트 스토리지 입출력은
|
|
10
|
+
이 패키지가 pxa-common 위에서 처리한다.
|
|
11
|
+
|
|
12
|
+
pxa-drm-server init # 시작 템플릿 생성
|
|
13
|
+
pxa-drm-server # 서버 실행
|
|
14
|
+
"""
|
|
15
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
__version__ = _version("pxa-drm-server")
|
|
19
|
+
except PackageNotFoundError: # 설치 없이 소스에서 바로 쓰는 경우
|
|
20
|
+
__version__ = "0.0.0.dev0"
|
|
21
|
+
|
|
22
|
+
# app 이 __version__ 을 쓰므로 버전을 정한 뒤에 import 한다.
|
|
23
|
+
from .app import create_app # noqa: E402
|
|
24
|
+
from .codes import DrmCode # noqa: E402
|
|
25
|
+
|
|
26
|
+
__all__ = ["create_app", "DrmCode", "__version__"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""REST API 패키지."""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""기본 제공 REST API (v1).
|
|
2
|
+
|
|
3
|
+
세 가지 작업(encrypt / decrypt / check)에 대해 각각 세 가지 대상 지정 방식을
|
|
4
|
+
지원한다. 라우터는 대상 해석과 실행을 service 에 위임하고 응답만 돌려준다.
|
|
5
|
+
|
|
6
|
+
GET|POST /api/v1/encrypt?filepath=/download/file.docx
|
|
7
|
+
GET|POST /api/v1/encrypt?uri=ABC:download/file.docx
|
|
8
|
+
POST /api/v1/encrypt (multipart: file=@file.docx)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from pxa_common import ApiResponse
|
|
15
|
+
from pxa_common.fastapi import APIRouter, Depends, File, Query, UploadFile
|
|
16
|
+
|
|
17
|
+
from ...runtime import DrmRuntime, get_runtime
|
|
18
|
+
from ...service import OPERATIONS, build_source, run_operation
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/api/v1", tags=["drm"])
|
|
21
|
+
|
|
22
|
+
_SUMMARY = {
|
|
23
|
+
"encrypt": "파일 암호화",
|
|
24
|
+
"decrypt": "파일 복호화",
|
|
25
|
+
"check": "파일 암호화 여부 확인",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_FILEPATH_DESC = "서버가 접근 가능한 파일 경로 (예: /download/file.docx)"
|
|
29
|
+
_URI_DESC = "오브젝트 스토리지 경로, '버킷별칭:오브젝트키' 형식 (예: ABC:download/file.docx)"
|
|
30
|
+
_RESPONSE_DESC = "첨부파일 요청의 응답 형식. file=결과 파일 다운로드, json=표준 JSON 응답"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _register(operation: str) -> None:
|
|
34
|
+
"""작업 하나에 대한 GET/POST 엔드포인트를 등록한다."""
|
|
35
|
+
summary = _SUMMARY[operation]
|
|
36
|
+
|
|
37
|
+
@router.get(
|
|
38
|
+
f"/{operation}",
|
|
39
|
+
summary=f"{summary} (filepath / uri)",
|
|
40
|
+
operation_id=f"{operation}_by_query",
|
|
41
|
+
response_model=ApiResponse,
|
|
42
|
+
)
|
|
43
|
+
async def _by_query(
|
|
44
|
+
filepath: Optional[str] = Query(None, description=_FILEPATH_DESC),
|
|
45
|
+
uri: Optional[str] = Query(None, description=_URI_DESC),
|
|
46
|
+
runtime: DrmRuntime = Depends(get_runtime),
|
|
47
|
+
):
|
|
48
|
+
source = build_source(runtime, filepath=filepath, uri=uri)
|
|
49
|
+
return await run_operation(runtime, operation, source)
|
|
50
|
+
|
|
51
|
+
@router.post(
|
|
52
|
+
f"/{operation}",
|
|
53
|
+
summary=f"{summary} (filepath / uri / 첨부파일)",
|
|
54
|
+
operation_id=f"{operation}_by_payload",
|
|
55
|
+
)
|
|
56
|
+
async def _by_payload(
|
|
57
|
+
filepath: Optional[str] = Query(None, description=_FILEPATH_DESC),
|
|
58
|
+
uri: Optional[str] = Query(None, description=_URI_DESC),
|
|
59
|
+
response: Optional[str] = Query(None, description=_RESPONSE_DESC),
|
|
60
|
+
file: Optional[UploadFile] = File(None, description="처리할 첨부파일"),
|
|
61
|
+
runtime: DrmRuntime = Depends(get_runtime),
|
|
62
|
+
):
|
|
63
|
+
source = build_source(runtime, filepath=filepath, uri=uri, file=file)
|
|
64
|
+
return await run_operation(runtime, operation, source, response_mode=response)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
for _operation_name in OPERATIONS:
|
|
68
|
+
_register(_operation_name)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.get("/health", summary="상태 확인", response_model=ApiResponse)
|
|
72
|
+
async def health(runtime: DrmRuntime = Depends(get_runtime)):
|
|
73
|
+
"""서버와 도메인 모듈이 준비되었는지 확인한다."""
|
|
74
|
+
return ApiResponse.ok(
|
|
75
|
+
result={
|
|
76
|
+
"status": "ok",
|
|
77
|
+
"domain": {
|
|
78
|
+
name: runtime.domain.has(name) for name in OPERATIONS
|
|
79
|
+
},
|
|
80
|
+
"buckets": sorted(runtime.storage.buckets),
|
|
81
|
+
},
|
|
82
|
+
message="서버가 정상 동작 중입니다.",
|
|
83
|
+
)
|
pxa_drm_server/app.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""FastAPI 앱 팩토리.
|
|
2
|
+
|
|
3
|
+
pxa-common 이 제공하는 기반(설정 / 로깅 / 표준응답 / 예외처리 / 메시지)을
|
|
4
|
+
한자리에서 조립한다. 개발자는 ``domain_core.py`` 만 작성하면 되고
|
|
5
|
+
이 파일을 수정할 일은 없다.
|
|
6
|
+
|
|
7
|
+
from pxa_drm_server import create_app
|
|
8
|
+
app = create_app()
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from pxa_common import (
|
|
18
|
+
RequestLoggingMiddleware,
|
|
19
|
+
get_app_config,
|
|
20
|
+
get_messages_config,
|
|
21
|
+
load_message_files,
|
|
22
|
+
register_exception_handlers,
|
|
23
|
+
register_messages,
|
|
24
|
+
setup_logging,
|
|
25
|
+
)
|
|
26
|
+
from pxa_common.fastapi import FastAPI
|
|
27
|
+
|
|
28
|
+
from . import __version__
|
|
29
|
+
from .api.v1 import router as v1_router
|
|
30
|
+
from .runtime import build_runtime
|
|
31
|
+
|
|
32
|
+
__all__ = ["create_app"]
|
|
33
|
+
|
|
34
|
+
_MESSAGE_FILE = Path(__file__).parent / "messages" / "drm.yaml"
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("pxa")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load_messages() -> None:
|
|
40
|
+
"""서버 기본 메시지 -> 애플리케이션 메시지(config) 순으로 등록한다."""
|
|
41
|
+
if _MESSAGE_FILE.is_file():
|
|
42
|
+
data = yaml.safe_load(_MESSAGE_FILE.read_text(encoding="utf-8")) or {}
|
|
43
|
+
register_messages(data)
|
|
44
|
+
files = get_messages_config().files
|
|
45
|
+
if files:
|
|
46
|
+
load_message_files(files)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_app() -> FastAPI:
|
|
50
|
+
"""DRM 서버 앱을 만든다."""
|
|
51
|
+
setup_logging()
|
|
52
|
+
_load_messages()
|
|
53
|
+
|
|
54
|
+
app_cfg = get_app_config()
|
|
55
|
+
runtime = build_runtime()
|
|
56
|
+
|
|
57
|
+
@asynccontextmanager
|
|
58
|
+
async def lifespan(app: FastAPI):
|
|
59
|
+
logger.info(
|
|
60
|
+
"%s 기동 완료 (debug=%s, buckets=%s)",
|
|
61
|
+
app_cfg.name, app_cfg.debug, sorted(runtime.storage.buckets) or "없음",
|
|
62
|
+
)
|
|
63
|
+
yield
|
|
64
|
+
logger.info("%s 종료", app_cfg.name)
|
|
65
|
+
|
|
66
|
+
app = FastAPI(
|
|
67
|
+
title=app_cfg.name,
|
|
68
|
+
description="파일 암복호화 API 서버 (pxa-common 기반)",
|
|
69
|
+
version=__version__,
|
|
70
|
+
debug=app_cfg.debug,
|
|
71
|
+
lifespan=lifespan,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# 요청 로깅(request-id + 처리시간) / 표준 예외 처리
|
|
75
|
+
app.add_middleware(RequestLoggingMiddleware)
|
|
76
|
+
register_exception_handlers(app)
|
|
77
|
+
|
|
78
|
+
app.state.drm_runtime = runtime
|
|
79
|
+
app.include_router(v1_router)
|
|
80
|
+
return app
|
pxa_drm_server/asgi.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""ASGI 진입점.
|
|
2
|
+
|
|
3
|
+
uvicorn pxa_drm_server.asgi:app --host 0.0.0.0 --port 8000
|
|
4
|
+
|
|
5
|
+
이 모듈을 import 하는 순간 설정을 읽고 domain_core 를 로드한다.
|
|
6
|
+
(CLI 가 이 모듈을 건드리지 않아야 ``pxa-drm-server init`` 이 domain_core 없이도 동작한다.)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .app import create_app
|
|
11
|
+
|
|
12
|
+
app = create_app()
|
pxa_drm_server/codes.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""DRM 서버 애플리케이션 코드.
|
|
2
|
+
|
|
3
|
+
pxa-common 의 ``AppCode`` 규약(코드 / 기본 메시지 / HTTP 상태)을 그대로 따른다.
|
|
4
|
+
공통 코드(pxa-10000, pxa-2000x)는 ``AppCode`` 를 쓰고, DRM 도메인에서만
|
|
5
|
+
의미가 있는 코드를 여기에 추가한다.
|
|
6
|
+
|
|
7
|
+
- pxa-21xxx : 도메인(암복호화) 처리 관련
|
|
8
|
+
- pxa-22xxx : 파일/스토리지 입출력 관련
|
|
9
|
+
|
|
10
|
+
새 코드가 필요하면 이 Enum 에만 한 줄 추가한다.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from enum import Enum
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DrmCode(str, Enum):
|
|
18
|
+
"""DRM 서버 전용 애플리케이션 코드."""
|
|
19
|
+
|
|
20
|
+
# ---- 도메인(개발자 구현) 처리 ----
|
|
21
|
+
ENCRYPT_FAILED = ("pxa-21001", "파일 암호화에 실패했습니다.", 200)
|
|
22
|
+
DECRYPT_FAILED = ("pxa-21002", "파일 복호화에 실패했습니다.", 200)
|
|
23
|
+
DOMAIN_ERROR = ("pxa-21003", "도메인 처리 중 오류가 발생했습니다.", 200)
|
|
24
|
+
DOMAIN_NOT_IMPLEMENTED = ("pxa-21004", "도메인 기능이 구현되지 않았습니다.", 200)
|
|
25
|
+
DOMAIN_CONTRACT_ERROR = ("pxa-21005", "도메인 함수의 반환값이 올바르지 않습니다.", 200)
|
|
26
|
+
|
|
27
|
+
# ---- 파일 / 스토리지 ----
|
|
28
|
+
FILE_NOT_FOUND = ("pxa-22001", "대상 파일을 찾을 수 없습니다.", 200)
|
|
29
|
+
PATH_NOT_ALLOWED = ("pxa-22002", "허용되지 않은 경로입니다.", 200)
|
|
30
|
+
INVALID_URI = ("pxa-22003", "uri 형식이 올바르지 않습니다.", 200)
|
|
31
|
+
BUCKET_NOT_CONFIGURED = ("pxa-22004", "설정되지 않은 버킷입니다.", 200)
|
|
32
|
+
STORAGE_ERROR = ("pxa-22005", "오브젝트 스토리지 처리 중 오류가 발생했습니다.", 200)
|
|
33
|
+
UPLOAD_ERROR = ("pxa-22006", "첨부파일 처리 중 오류가 발생했습니다.", 200)
|
|
34
|
+
FILE_TOO_LARGE = ("pxa-22007", "파일 크기가 허용 범위를 초과했습니다.", 200)
|
|
35
|
+
|
|
36
|
+
def __new__(cls, code: str, message: str, http_status: int):
|
|
37
|
+
obj = str.__new__(cls, code)
|
|
38
|
+
obj._value_ = code
|
|
39
|
+
obj.code = code
|
|
40
|
+
obj.message = message
|
|
41
|
+
obj.http_status = http_status
|
|
42
|
+
return obj
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_success(self) -> bool:
|
|
46
|
+
return self.code.startswith("pxa-1")
|
pxa_drm_server/config.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""DRM 서버 설정.
|
|
2
|
+
|
|
3
|
+
pxa-common 의 설정 관리(``load_section``)를 그대로 사용한다.
|
|
4
|
+
우선순위는 **환경변수(PXA_*) > config.yaml > 기본값** 이며,
|
|
5
|
+
config 파일 경로는 환경변수 ``PXA_CONFIG`` 로 지정한다.
|
|
6
|
+
|
|
7
|
+
오브젝트 스토리지 접근키처럼 민감한 값은 파일에 두지 말고 환경변수로 넣는다::
|
|
8
|
+
|
|
9
|
+
PXA_STORAGE__BUCKETS__ABC__ACCESS_KEY=...
|
|
10
|
+
PXA_STORAGE__BUCKETS__ABC__SECRET_KEY=...
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from pxa_common import load_section
|
|
17
|
+
from pxa_common.fastapi import BaseModel, Field, model_validator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DrmConfig(BaseModel):
|
|
21
|
+
"""domain_core 로딩과 작업 방식."""
|
|
22
|
+
|
|
23
|
+
# 개발자가 구현한 모듈. 파일 경로(domain_file)를 주면 그쪽이 우선한다.
|
|
24
|
+
domain_module: str = "domain_core"
|
|
25
|
+
domain_file: Optional[str] = None
|
|
26
|
+
# 임시 작업 디렉토리. 비우면 OS 임시 디렉토리를 쓴다.
|
|
27
|
+
work_dir: Optional[str] = None
|
|
28
|
+
# 첨부파일 업로드 허용 크기(MB). 0 이하면 제한 없음.
|
|
29
|
+
max_upload_size_mb: int = 512
|
|
30
|
+
# 첨부파일 요청의 기본 응답 형식: "file"(결과 파일 다운로드) 또는 "json".
|
|
31
|
+
upload_response: str = "file"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LocalConfig(BaseModel):
|
|
35
|
+
"""filepath(서버 로컬 경로) 처리 방식."""
|
|
36
|
+
|
|
37
|
+
# 비우면 모든 경로를 허용한다. 지정하면 해당 디렉토리 하위만 허용한다.
|
|
38
|
+
allowed_roots: list[str] = Field(default_factory=list)
|
|
39
|
+
# True 면 원본 파일을 그대로 도메인에 넘긴다(제자리 처리).
|
|
40
|
+
# False 면 작업본을 만들어 처리하고 성공 시에만 원자적으로 교체한다.
|
|
41
|
+
in_place: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BucketConfig(BaseModel):
|
|
45
|
+
"""S3 호환 오브젝트 스토리지 버킷 하나의 접근 정보."""
|
|
46
|
+
|
|
47
|
+
bucket: str
|
|
48
|
+
endpoint_url: Optional[str] = None
|
|
49
|
+
access_key: str = ""
|
|
50
|
+
secret_key: str = ""
|
|
51
|
+
region: str = "us-east-1"
|
|
52
|
+
# MinIO/Ceph 등 S3 호환 스토리지는 보통 "path" 가 안전하다.
|
|
53
|
+
addressing_style: str = "path"
|
|
54
|
+
signature_version: Optional[str] = None
|
|
55
|
+
verify_ssl: bool = True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class StorageConfig(BaseModel):
|
|
59
|
+
"""버킷 별칭 -> 접근 정보. uri 의 'ABC:key' 에서 ABC 가 별칭이다."""
|
|
60
|
+
|
|
61
|
+
buckets: dict[str, BucketConfig] = Field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
@model_validator(mode="before")
|
|
64
|
+
@classmethod
|
|
65
|
+
def _merge_aliases(cls, data):
|
|
66
|
+
"""대소문자만 다른 별칭을 하나로 합친다.
|
|
67
|
+
|
|
68
|
+
환경변수 override(PXA_STORAGE__BUCKETS__ABC__ACCESS_KEY)는 pxa-common 에서
|
|
69
|
+
경로가 소문자로 변환되므로, config.yaml 의 'ABC' 와 환경변수의 'abc' 가
|
|
70
|
+
따로 생긴다. 둘을 합쳐야 "키는 환경변수로" 라는 사용법이 성립한다.
|
|
71
|
+
나중에 온 값(환경변수)이 이긴다.
|
|
72
|
+
"""
|
|
73
|
+
if not isinstance(data, dict):
|
|
74
|
+
return data
|
|
75
|
+
buckets = data.get("buckets")
|
|
76
|
+
if not isinstance(buckets, dict):
|
|
77
|
+
return data
|
|
78
|
+
merged: dict = {}
|
|
79
|
+
for alias, value in buckets.items():
|
|
80
|
+
key = str(alias).lower()
|
|
81
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
82
|
+
merged[key] = {**merged[key], **value}
|
|
83
|
+
else:
|
|
84
|
+
merged[key] = value
|
|
85
|
+
return {**data, "buckets": merged}
|
|
86
|
+
|
|
87
|
+
def resolve(self, alias: str) -> Optional[BucketConfig]:
|
|
88
|
+
"""버킷 별칭을 찾는다(대소문자 무시)."""
|
|
89
|
+
return self.buckets.get(alias.lower())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_drm_config() -> DrmConfig:
|
|
93
|
+
return load_section("drm", DrmConfig)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_local_config() -> LocalConfig:
|
|
97
|
+
return load_section("local", LocalConfig)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_storage_config() -> StorageConfig:
|
|
101
|
+
return load_section("storage", StorageConfig)
|
pxa_drm_server/domain.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""domain_core 로더 / 호출 어댑터.
|
|
2
|
+
|
|
3
|
+
서버는 기동 시 개발자가 작성한 ``domain_core`` 모듈을 읽어 들이고,
|
|
4
|
+
요청이 오면 ``encrypt(filepath)`` / ``decrypt(filepath)`` / ``check(filepath)``
|
|
5
|
+
를 호출한다. 개발자는 이 파일을 볼 필요가 없다.
|
|
6
|
+
|
|
7
|
+
- 동기 함수는 워커 스레드에서 실행해 이벤트 루프를 막지 않는다.
|
|
8
|
+
- ``async def`` 로 구현해도 그대로 동작한다.
|
|
9
|
+
- 반환값은 반드시 ``True`` / ``False`` 여야 한다.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib
|
|
14
|
+
import importlib.util
|
|
15
|
+
import inspect
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Optional
|
|
20
|
+
|
|
21
|
+
from starlette.concurrency import run_in_threadpool
|
|
22
|
+
|
|
23
|
+
from .config import DrmConfig, get_drm_config
|
|
24
|
+
from .errors import DomainContractError, DomainError, DomainNotImplementedError
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("pxa")
|
|
27
|
+
|
|
28
|
+
# 개발자가 구현해야 하는 함수. encrypt/decrypt 는 필수, check 는 선택이다.
|
|
29
|
+
REQUIRED_FUNCTIONS = ("encrypt", "decrypt")
|
|
30
|
+
OPTIONAL_FUNCTIONS = ("check",)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_module_from_file(path: Path):
|
|
34
|
+
"""파일 경로에서 모듈을 직접 읽어 들인다(config: drm.domain_file)."""
|
|
35
|
+
spec = importlib.util.spec_from_file_location("domain_core", path)
|
|
36
|
+
if spec is None or spec.loader is None:
|
|
37
|
+
raise ImportError(f"도메인 파일을 읽을 수 없습니다: {path}")
|
|
38
|
+
module = importlib.util.module_from_spec(spec)
|
|
39
|
+
sys.modules["domain_core"] = module
|
|
40
|
+
spec.loader.exec_module(module)
|
|
41
|
+
return module
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_domain_module(cfg: Optional[DrmConfig] = None):
|
|
45
|
+
"""설정에 따라 domain_core 모듈을 로드하고 계약을 검증한다."""
|
|
46
|
+
cfg = cfg or get_drm_config()
|
|
47
|
+
|
|
48
|
+
if cfg.domain_file:
|
|
49
|
+
path = Path(cfg.domain_file).expanduser().resolve()
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
raise RuntimeError(f"drm.domain_file 경로에 파일이 없습니다: {path}")
|
|
52
|
+
module = _load_module_from_file(path)
|
|
53
|
+
origin = str(path)
|
|
54
|
+
else:
|
|
55
|
+
# 프로젝트 루트(실행 디렉토리)의 domain_core.py 도 찾을 수 있게 한다.
|
|
56
|
+
cwd = str(Path.cwd())
|
|
57
|
+
if cwd not in sys.path:
|
|
58
|
+
sys.path.insert(0, cwd)
|
|
59
|
+
try:
|
|
60
|
+
module = importlib.import_module(cfg.domain_module)
|
|
61
|
+
except ImportError as exc:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
f"도메인 모듈 '{cfg.domain_module}' 을(를) 찾을 수 없습니다. "
|
|
64
|
+
f"domain_core.py 를 만들거나 config 의 drm.domain_module / "
|
|
65
|
+
f"drm.domain_file 을 확인하세요."
|
|
66
|
+
) from exc
|
|
67
|
+
origin = getattr(module, "__file__", cfg.domain_module)
|
|
68
|
+
|
|
69
|
+
missing = [n for n in REQUIRED_FUNCTIONS if not callable(getattr(module, n, None))]
|
|
70
|
+
if missing:
|
|
71
|
+
raise RuntimeError(
|
|
72
|
+
f"도메인 모듈({origin})에 다음 함수가 없습니다: {', '.join(missing)}. "
|
|
73
|
+
f"각 함수는 filepath 하나를 받아 True/False 를 반환해야 합니다."
|
|
74
|
+
)
|
|
75
|
+
for name in OPTIONAL_FUNCTIONS:
|
|
76
|
+
if not callable(getattr(module, name, None)):
|
|
77
|
+
logger.warning(
|
|
78
|
+
"도메인 모듈에 %s() 가 없습니다. /api/v1/%s 호출 시 pxa-21004 로 응답합니다.",
|
|
79
|
+
name, name,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
logger.info("도메인 모듈 로드: %s", origin)
|
|
83
|
+
return module
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DomainAdapter:
|
|
87
|
+
"""도메인 함수 호출을 감싸 예외/반환값을 표준 오류로 바꾼다."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, module: Any):
|
|
90
|
+
self.module = module
|
|
91
|
+
|
|
92
|
+
def has(self, name: str) -> bool:
|
|
93
|
+
return callable(getattr(self.module, name, None))
|
|
94
|
+
|
|
95
|
+
def _resolve(self, name: str) -> Callable[..., Any]:
|
|
96
|
+
func = getattr(self.module, name, None)
|
|
97
|
+
if not callable(func):
|
|
98
|
+
raise DomainNotImplementedError(
|
|
99
|
+
f"domain_core.{name}() 가 구현되어 있지 않습니다."
|
|
100
|
+
)
|
|
101
|
+
return func
|
|
102
|
+
|
|
103
|
+
async def call(self, name: str, filepath: Path) -> bool:
|
|
104
|
+
"""domain_core.<name>(filepath) 를 호출하고 bool 을 돌려준다."""
|
|
105
|
+
func = self._resolve(name)
|
|
106
|
+
try:
|
|
107
|
+
if inspect.iscoroutinefunction(func):
|
|
108
|
+
result = await func(str(filepath))
|
|
109
|
+
else:
|
|
110
|
+
result = await run_in_threadpool(func, str(filepath))
|
|
111
|
+
except Exception as exc: # 개발자 코드의 모든 예외를 표준 응답으로
|
|
112
|
+
logger.exception("domain_core.%s() 실행 중 예외", name)
|
|
113
|
+
raise DomainError(
|
|
114
|
+
f"domain_core.{name}() 실행 중 오류가 발생했습니다: {exc}"
|
|
115
|
+
) from exc
|
|
116
|
+
|
|
117
|
+
if not isinstance(result, bool):
|
|
118
|
+
raise DomainContractError(
|
|
119
|
+
f"domain_core.{name}() 는 True/False 를 반환해야 합니다 "
|
|
120
|
+
f"(반환값 타입: {type(result).__name__})."
|
|
121
|
+
)
|
|
122
|
+
return result
|
pxa_drm_server/errors.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""DRM 서버 전용 예외.
|
|
2
|
+
|
|
3
|
+
모두 pxa-common 의 ``AppError`` 를 상속하므로
|
|
4
|
+
``register_exception_handlers(app)`` 한 줄로 표준 응답(HTTP 200 + 코드)으로
|
|
5
|
+
자동 변환된다. 개발자는 예외를 잡을 필요가 없다.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from pxa_common import AppError
|
|
12
|
+
|
|
13
|
+
from .codes import DrmCode
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DrmError(AppError):
|
|
17
|
+
"""DRM 서버 예외 베이스."""
|
|
18
|
+
|
|
19
|
+
code_enum: DrmCode = DrmCode.DOMAIN_ERROR
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: Optional[str] = None, result: Any = None):
|
|
22
|
+
super().__init__(self.code_enum, message, result)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EncryptFailedError(DrmError):
|
|
26
|
+
"""domain_core.encrypt() 가 False 를 반환 (pxa-21001)."""
|
|
27
|
+
|
|
28
|
+
code_enum = DrmCode.ENCRYPT_FAILED
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DecryptFailedError(DrmError):
|
|
32
|
+
"""domain_core.decrypt() 가 False 를 반환 (pxa-21002)."""
|
|
33
|
+
|
|
34
|
+
code_enum = DrmCode.DECRYPT_FAILED
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DomainError(DrmError):
|
|
38
|
+
"""domain_core 함수가 예외를 던짐 (pxa-21003)."""
|
|
39
|
+
|
|
40
|
+
code_enum = DrmCode.DOMAIN_ERROR
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DomainNotImplementedError(DrmError):
|
|
44
|
+
"""domain_core 에 해당 함수가 없음 (pxa-21004)."""
|
|
45
|
+
|
|
46
|
+
code_enum = DrmCode.DOMAIN_NOT_IMPLEMENTED
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DomainContractError(DrmError):
|
|
50
|
+
"""domain_core 함수가 True/False 가 아닌 값을 반환 (pxa-21005)."""
|
|
51
|
+
|
|
52
|
+
code_enum = DrmCode.DOMAIN_CONTRACT_ERROR
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TargetNotFoundError(DrmError):
|
|
56
|
+
"""대상 파일/오브젝트 없음 (pxa-22001)."""
|
|
57
|
+
|
|
58
|
+
code_enum = DrmCode.FILE_NOT_FOUND
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PathNotAllowedError(DrmError):
|
|
62
|
+
"""allowed_roots 밖의 경로 (pxa-22002)."""
|
|
63
|
+
|
|
64
|
+
code_enum = DrmCode.PATH_NOT_ALLOWED
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class InvalidUriError(DrmError):
|
|
68
|
+
"""uri 형식 오류 (pxa-22003)."""
|
|
69
|
+
|
|
70
|
+
code_enum = DrmCode.INVALID_URI
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BucketNotConfiguredError(DrmError):
|
|
74
|
+
"""config 에 없는 버킷 별칭 (pxa-22004)."""
|
|
75
|
+
|
|
76
|
+
code_enum = DrmCode.BUCKET_NOT_CONFIGURED
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class StorageError(DrmError):
|
|
80
|
+
"""오브젝트 스토리지 입출력 오류 (pxa-22005)."""
|
|
81
|
+
|
|
82
|
+
code_enum = DrmCode.STORAGE_ERROR
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class UploadError(DrmError):
|
|
86
|
+
"""첨부파일 처리 오류 (pxa-22006)."""
|
|
87
|
+
|
|
88
|
+
code_enum = DrmCode.UPLOAD_ERROR
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FileTooLargeError(DrmError):
|
|
92
|
+
"""허용 크기 초과 (pxa-22007)."""
|
|
93
|
+
|
|
94
|
+
code_enum = DrmCode.FILE_TOO_LARGE
|