Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6930723
add: passphrase to model
KO1231 Dec 20, 2025
119f462
add: DelibirdLinkNonceTable
KO1231 Dec 20, 2025
98e0d39
update: protected時にnonceとchallengeを皇族に渡さないように
KO1231 Dec 20, 2025
8961327
fix: DelibirdNonceTablemodel.mark_used()
KO1231 Dec 20, 2025
607b8e3
update: bootstrap icons to 1.13.1
KO1231 Dec 20, 2025
706cb5e
add: error-protected.css
KO1231 Dec 20, 2025
065897a
add: protected.html
KO1231 Dec 20, 2025
efbbc6e
add: response_util use_bootstrap_icons
KO1231 Dec 20, 2025
a42c00b
add: lambda->nonce-table iam policy
KO1231 Dec 20, 2025
3bdeec1
fix: bootstrap icons integrity
KO1231 Dec 20, 2025
d2e6cc2
add: NONCE_LIFETIME_SECONDS
KO1231 Dec 20, 2025
6e53fb8
add: putitem action to policy lambda->nonce-table
KO1231 Dec 20, 2025
2e980b7
add: default_error_message to protected.html
KO1231 Dec 20, 2025
3727014
fix: DelibirdNonceTableModel.mark_used
KO1231 Dec 20, 2025
cfd8705
add: 実装
KO1231 Dec 20, 2025
a80632e
fix: css to hidden-field
KO1231 Dec 20, 2025
a3eb3bd
add: password show toggle button
KO1231 Dec 20, 2025
07733ab
add: links.html protectedの時に鍵マーク
KO1231 Dec 20, 2025
4bbbe38
add: パスワードの作成・編集をportalでできるように
KO1231 Dec 20, 2025
bf81f5a
refactor: admin-portal html
KO1231 Dec 20, 2025
020e2da
refactor: script_nonce
KO1231 Dec 20, 2025
918cadb
refactor: use hmac.compare_digest
KO1231 Dec 20, 2025
795e595
refactor: dev環境でのdynamodbのlogging levelをDEBUGに
KO1231 Dec 20, 2025
22793fd
fix: nonce lifetime
KO1231 Dec 20, 2025
f9f6ee7
fix: parse_util allow_notfoundの取り扱い
KO1231 Dec 20, 2025
bf40eba
fix: passphraseが空白時の取り扱い
KO1231 Dec 20, 2025
1c1c58e
refactor: error-protected.css
KO1231 Dec 20, 2025
40c9ba5
fix: nonceの消込タイミング
KO1231 Dec 20, 2025
a30d2f8
fix: none check
KO1231 Dec 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions environments/sample/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ module "aws_iam" {
arn = module.aws_dynamodb.link_table.arn
}

ddb_link_nonce_table = {
name = module.aws_dynamodb.link_nonce_table.name
arn = module.aws_dynamodb.link_nonce_table.arn
}

lambda_redirect_request = {
name = module.aws_lambda.lambda_redirect_request.function_name
arn = module.aws_lambda.lambda_redirect_request.arn
Expand All @@ -40,6 +45,11 @@ module "aws_lambda" {
arn = module.aws_dynamodb.link_table.arn
}

ddb_link_nonce_table = {
name = module.aws_dynamodb.link_nonce_table.name
arn = module.aws_dynamodb.link_nonce_table.arn
}

role_redirect_request = {
name = module.aws_iam.role_lambda_redirect_request.name
arn = module.aws_iam.role_lambda_redirect_request.arn
Expand All @@ -50,9 +60,10 @@ module "aws_lambda" {
arn = module.aws_iam.role_lambda_admin_portal.arn
}

link_prefix = "" # TODO: Change value as needed (e.g. https://example.com/dev/.... -> "dev")
allowed_domain = local.config["allowed_domain"]
reserved_concurrent_executions = local.config["aws"]["lambda"]["reserved_concurrent_executions"]
link_prefix = "" # TODO: Change value as needed (e.g. https://example.com/dev/.... -> "dev")
allowed_domain = local.config["allowed_domain"]
protected_link_request_nonce_lifetime = 300 # TODO: Change value as needed
reserved_concurrent_executions = local.config["aws"]["lambda"]["reserved_concurrent_executions"]
Comment thread
KO1231 marked this conversation as resolved.
}

module "aws_s3" {
Expand Down
3 changes: 2 additions & 1 deletion lambda/admin_portal/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
from portal_page.link_create import PortalLinkCreatePage
from portal_page.link_list import PortalListPage
from portal_page.link_update import PortalLinkUpdatePage
from util.logger_util import setup_logger
from util.logger_util import setup_logger, setup_dev_logger
from util.parse_util import parse_domain, parse_request_path
from util.response_util import error_response

logger = setup_logger("admin_portal")
setup_dev_logger()


@event_source(data_class=APIGatewayProxyEvent)
Expand Down
2 changes: 2 additions & 0 deletions lambda/admin_portal/portal_page/link_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def _parse_request_data(cls, domain: str, _body: str) -> Optional[DelibirdLink]:
tag=set(body["tag"]) if "tag" in body else None,
expiration_date=as_jst(datetime.fromisoformat(str(body["expiration_date"]))) if "expiration_date" in body else None,
expired_origin=str(body["expired_origin"]) if "expired_origin" in body else None,
_passphrase=str(body["passphrase"]) if "passphrase" in body else None,
Comment thread
KO1231 marked this conversation as resolved.
query_omit=bool(body["query_omit"]),
query_whitelist=set(body["query_whitelist"]) if "query_whitelist" in body else None,
max_uses=int(body["max_uses"]) if "max_uses" in body else None,
Expand Down Expand Up @@ -84,6 +85,7 @@ def perform(cls, domain: str, event: APIGatewayProxyEvent):
tag=link_data.tag,
expiration_date=link_data.expiration_date,
expired_origin=link_data.expired_origin,
passphrase=link_data._passphrase, # allow read private field
query_omit=link_data.query_omit,
query_whitelist=link_data.query_whitelist,
max_uses=link_data.max_uses
Expand Down
2 changes: 2 additions & 0 deletions lambda/admin_portal/portal_page/link_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def _parse_request_data(cls, domain: str, _body: str) -> Optional[DelibirdLink]:
tag=set(body["tag"]) if "tag" in body else None,
expiration_date=as_jst(datetime.fromisoformat(str(body["expiration_date"]))) if "expiration_date" in body else None,
expired_origin=str(body["expired_origin"]) if "expired_origin" in body else None,
_passphrase=str(body["passphrase"]) if "passphrase" in body else None,
Comment thread
KO1231 marked this conversation as resolved.
query_omit=bool(body["query_omit"]),
query_whitelist=set(body["query_whitelist"]) if "query_whitelist" in body else None,
max_uses=int(body["max_uses"]) if "max_uses" in body else None,
Expand Down Expand Up @@ -77,6 +78,7 @@ def perform(cls, domain: str, event: APIGatewayProxyEvent):
DelibirdLinkTableModel.tag.set(link_data.tag),
DelibirdLinkTableModel.expiration_date.set(link_data.expiration_date),
DelibirdLinkTableModel.expired_origin.set(link_data.expired_origin),
DelibirdLinkTableModel.passphrase.set(link_data._passphrase), # allow read private field
DelibirdLinkTableModel.query_omit.set(link_data.query_omit),
DelibirdLinkTableModel.query_whitelist.set(link_data.query_whitelist),
DelibirdLinkTableModel.max_uses.set(link_data.max_uses),
Expand Down
108 changes: 106 additions & 2 deletions lambda/admin_portal/static/links.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" integrity="sha384-XGjxtQfXaH2tnPFa9x+ruJTuLE3Aa6LhHSWRr1XeTyhezb4abCG4ccI5AkVDxqC+" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css" integrity="sha384-CK2SzKma4jA5H/MXDUU7i1TqZlCFaD4T01vtyDFvPlD97JQyS+IsSh1nI2EFbpyk" crossorigin="anonymous">
<link rel="stylesheet" href="https://static.kazutech.jp/l/css/admin-portal.css">
</head>
<body class="bg-light">
Expand Down Expand Up @@ -110,6 +110,11 @@ <h5 class="mb-0"><i class="bi bi-table"></i> Link Details</h5>
data-bs-html="true"
title="{{ link.tag|join(', ') }}"></i>
{% endif %}
{% if link.is_protected() %}
<i class="bi bi-lock-fill ms-1"
data-bs-toggle="tooltip"
data-bs-placement="top"></i>
{% endif %}
</td>

<!-- Origin -->
Expand Down Expand Up @@ -201,6 +206,7 @@ <h5 class="mb-0"><i class="bi bi-table"></i> Link Details</h5>
data-max-uses="{{ link.max_uses or '' }}"
data-expiration="{{ link.expiration_date.strftime('%Y-%m-%dT%H:%M') if link.expiration_date else '' }}"
data-expired-origin="{{ link.expired_origin or '' }}"
data-passphrase="{{ link._passphrase or '' }}"
Comment thread
KO1231 marked this conversation as resolved.
data-query-omit="{{ 'true' if link.query_omit else 'false' }}"
data-query-whitelist="{{ link.query_whitelist|join(',') if link.query_whitelist else '' }}"
data-memo="{{ link.memo or '' }}"
Expand Down Expand Up @@ -320,6 +326,19 @@ <h5 class="modal-title" id="createLinkModalLabel">
<div class="form-text">期限切れ時のリダイレクト先URL(オプション)</div>
</div>

<!-- Passphrase -->
<div class="mb-3">
<label for="passphrase" class="form-label">Passphrase</label>
<div class="password-field-wrapper">
<input type="password" class="form-control password-input-field" id="passphrase" name="passphrase"
placeholder="パスワードを入力" autocomplete="off">
<button type="button" class="password-toggle-btn" id="togglePassphrase" aria-label="パスワードの表示切替">
<i class="bi bi-eye" aria-hidden="true"></i>
</button>
</div>
<div class="form-text">リンクにアクセスするためのパスワード(オプション)</div>
</div>

<!-- Memo -->
<div class="mb-3">
<label for="memo" class="form-label">Memo</label>
Expand Down Expand Up @@ -444,6 +463,19 @@ <h5 class="modal-title" id="editLinkModalLabel">
<div class="form-text">期限切れ時のリダイレクト先URL(オプション)</div>
</div>

<!-- Passphrase -->
<div class="mb-3">
<label for="editPassphrase" class="form-label">Passphrase</label>
<div class="password-field-wrapper">
<input type="password" class="form-control password-input-field" id="editPassphrase" name="passphrase"
placeholder="パスワードを入力" autocomplete="off">
<button type="button" class="password-toggle-btn" id="toggleEditPassphrase" aria-label="パスワードの表示切替">
<i class="bi bi-eye" aria-hidden="true"></i>
</button>
</div>
<div class="form-text">リンクにアクセスするためのパスワード(オプション)</div>
</div>

<!-- Memo -->
<div class="mb-3">
<label for="editMemo" class="form-label">Memo</label>
Expand Down Expand Up @@ -483,7 +515,7 @@ <h5 class="modal-title" id="editLinkModalLabel">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>

<!-- Custom JS -->
<script{% if script_nonce %} nonce="{{ script_nonce }}"{% endif %}>
<script {%- if script_nonce %} nonce="{{ script_nonce }}"{%- endif %}>
// Query Omit チェックボックスの制御
document.addEventListener('DOMContentLoaded', function() {
// Bootstrap ツールチップの初期化
Expand All @@ -492,6 +524,32 @@ <h5 class="modal-title" id="editLinkModalLabel">
return new bootstrap.Tooltip(tooltipTriggerEl);
});

// パスワード表示トグル機能(作成モーダル)
const togglePassphraseBtn = document.getElementById('togglePassphrase');
const passphraseInput = document.getElementById('passphrase');
if (togglePassphraseBtn && passphraseInput) {
togglePassphraseBtn.addEventListener('click', function() {
const isPassword = passphraseInput.type === 'password';
passphraseInput.type = isPassword ? 'text' : 'password';
const icon = togglePassphraseBtn.querySelector('i');
icon.classList.toggle('bi-eye');
icon.classList.toggle('bi-eye-slash');
});
}

// パスワード表示トグル機能(編集モーダル)
const toggleEditPassphraseBtn = document.getElementById('toggleEditPassphrase');
const editPassphraseInput = document.getElementById('editPassphrase');
if (toggleEditPassphraseBtn && editPassphraseInput) {
toggleEditPassphraseBtn.addEventListener('click', function() {
const isPassword = editPassphraseInput.type === 'password';
editPassphraseInput.type = isPassword ? 'text' : 'password';
const icon = toggleEditPassphraseBtn.querySelector('i');
icon.classList.toggle('bi-eye');
icon.classList.toggle('bi-eye-slash');
});
}

const queryOmitCheckbox = document.getElementById('queryOmit');
const queryWhitelistContainer = document.getElementById('queryWhitelistContainer');
const queryWhitelistInput = document.getElementById('queryWhitelist');
Expand Down Expand Up @@ -566,6 +624,11 @@ <h5 class="modal-title" id="editLinkModalLabel">
data.expired_origin = expiredOrigin;
}

const passphrase = document.getElementById('passphrase').value;
if (passphrase) {
data.passphrase = passphrase;
}

const memo = document.getElementById('memo').value;
if (memo) {
data.memo = memo;
Expand Down Expand Up @@ -616,6 +679,18 @@ <h5 class="modal-title" id="editLinkModalLabel">
createLinkModal.addEventListener('hidden.bs.modal', function() {
createLinkForm.reset();
updateQueryWhitelistState();

// パスワード入力欄を非表示状態にリセット
if (passphraseInput) {
passphraseInput.type = 'password';
}
if (togglePassphraseBtn) {
const icon = togglePassphraseBtn.querySelector('i');
if (icon) {
icon.classList.remove('bi-eye-slash');
icon.classList.add('bi-eye');
}
}
});

// === 編集機能 ===
Expand Down Expand Up @@ -649,6 +724,7 @@ <h5 class="modal-title" id="editLinkModalLabel">
const maxUses = this.getAttribute('data-max-uses');
const expiration = this.getAttribute('data-expiration');
const expiredOrigin = this.getAttribute('data-expired-origin');
const passphrase = this.getAttribute('data-passphrase');
const queryOmit = this.getAttribute('data-query-omit') === 'true';
const queryWhitelist = this.getAttribute('data-query-whitelist');
const memo = this.getAttribute('data-memo');
Expand All @@ -667,6 +743,17 @@ <h5 class="modal-title" id="editLinkModalLabel">
document.getElementById('editMemo').value = memo;
document.getElementById('editTag').value = tag;

// パスワードを表示(既存のパスワードを確認できるように)
document.getElementById('editPassphrase').value = passphrase;
document.getElementById('editPassphrase').type = 'password';
if (toggleEditPassphraseBtn) {
const icon = toggleEditPassphraseBtn.querySelector('i');
if (icon) {
icon.classList.remove('bi-eye-slash');
icon.classList.add('bi-eye');
}
}

// Query Whitelist の状態を更新
updateEditQueryWhitelistState();
});
Expand Down Expand Up @@ -712,6 +799,11 @@ <h5 class="modal-title" id="editLinkModalLabel">
data.expired_origin = expiredOrigin;
}

const passphrase = document.getElementById('editPassphrase').value;
if (passphrase) {
data.passphrase = passphrase;
}

const memo = document.getElementById('editMemo').value;
if (memo) {
data.memo = memo;
Expand Down Expand Up @@ -760,6 +852,18 @@ <h5 class="modal-title" id="editLinkModalLabel">
editLinkModal.addEventListener('hidden.bs.modal', function() {
editLinkForm.reset();
updateEditQueryWhitelistState();

// パスワード入力欄を非表示状態にリセット
if (editPassphraseInput) {
editPassphraseInput.type = 'password';
}
if (toggleEditPassphraseBtn) {
const icon = toggleEditPassphraseBtn.querySelector('i');
if (icon) {
icon.classList.remove('bi-eye-slash');
icon.classList.add('bi-eye');
}
}
});
});
</script>
Expand Down
20 changes: 20 additions & 0 deletions lambda/layers/common/python/ddb/models/delibird_link.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import hashlib
import hmac
import json
import logging
import os
Expand Down Expand Up @@ -53,6 +55,8 @@ class DelibirdLink:
expiration_date: Optional[datetime] = None
expired_origin: Optional[str] = None

_passphrase: Optional[str] = None

query_omit: bool = False
query_whitelist: set[str] = None

Expand All @@ -72,6 +76,9 @@ def _validation(link: "DelibirdLink") -> None:
# status
if not link.status.is_redirection:
raise ValueError(f"Status code {link.status} is not a redirection status.")
# passphrase
if (link._passphrase is not None) and link._passphrase.isspace():
link._passphrase = None

def __post_init__(self):
if self.query_whitelist is None:
Expand Down Expand Up @@ -109,6 +116,17 @@ def increment_uses(self) -> bool:
self.uses += 1
return True

def is_protected(self) -> bool:
return (self._passphrase is not None) and (not self._passphrase.isspace())

def validate_challenge(self, nonce: str, challenge: str) -> bool:
if not self.is_protected():
raise ValueError("Link is not protected.")
if not nonce:
raise ValueError("Nonce is required.")
expected = hashlib.sha256(f"{self._passphrase}#{nonce}".encode()).hexdigest()
return hmac.compare_digest(challenge, expected)

@staticmethod
def from_model(model: "DelibirdLinkTableModel") -> "DelibirdLink":
return DelibirdLink(
Expand All @@ -123,6 +141,7 @@ def from_model(model: "DelibirdLinkTableModel") -> "DelibirdLink":
tag=set(model.tag) if model.tag is not None else None,
expiration_date=as_jst(model.expiration_date) if model.expiration_date is not None else None,
expired_origin=model.expired_origin,
_passphrase=model.passphrase,
query_omit=model.query_omit,
query_whitelist=model.query_whitelist,
max_uses=int(model.max_uses) if model.max_uses is not None else None
Expand All @@ -147,6 +166,7 @@ class Meta:

expiration_date = DateTimeAttribute(null=True)
expired_origin = UnicodeAttribute(null=True)
passphrase = UnicodeAttribute(null=True)
query_omit = BooleanAttribute(null=False, default=True)
query_whitelist = UnicodeSetAttribute(null=True)
max_uses = NumberAttribute(null=True)
Expand Down
9 changes: 9 additions & 0 deletions lambda/layers/common/python/util/logger_util.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import os
import sys

_FORMAT = '%(levelname)s %(asctime)s [%(name)s - %(funcName)s] %(message)s'
Expand Down Expand Up @@ -32,3 +33,11 @@ def setup_logger(name: str, level: int = logging.DEBUG):
logger.addHandler(_ERROR_HANDLER)
# logger.addHandler(_LOG_FILE_HANDLER)
return logger


def setup_dev_logger():
if os.environ.get("DELIBIRD_ENV") != "dev":
return
log_pynamodb = logging.getLogger("pynamodb")
log_pynamodb.setLevel(logging.DEBUG)
log_pynamodb.addHandler(_HANDLER)
Loading