Clarify transfer rejections and improve storage readability
This commit is contained in:
@@ -616,25 +616,36 @@ export class Service {
|
||||
input.files?.some((f) => f.size > this.admin.state.perFileBytes)
|
||||
)
|
||||
fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.');
|
||||
const rejected = this.db
|
||||
.select()
|
||||
// Count decisions for this direction only. Acceptance resets the streak;
|
||||
// cancelling or letting a request expire must not bypass repeated rejections.
|
||||
const decisions = this.db
|
||||
.select({ status: s.requests.status, rejectedAt: s.requests.rejectedAt })
|
||||
.from(s.requests)
|
||||
.where(
|
||||
and(
|
||||
eq(s.requests.senderGuestId, g.id),
|
||||
eq(s.requests.recipientGuestId, target!.guestId),
|
||||
eq(s.requests.status, 'rejected'),
|
||||
gt(s.requests.rejectedAt, Date.now() - this.cfg.rejectCooldownMs),
|
||||
or(
|
||||
eq(s.requests.status, 'accepted'),
|
||||
and(eq(s.requests.status, 'rejected'), sql`${s.requests.rejectedAt} IS NOT NULL`),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(s.requests.rejectedAt))
|
||||
.get();
|
||||
if (rejected)
|
||||
.orderBy(desc(s.requests.createdAt), desc(sql`${s.requests}.rowid`))
|
||||
.limit(3)
|
||||
.all();
|
||||
if (
|
||||
decisions.length === 3 &&
|
||||
decisions.every(
|
||||
(row) =>
|
||||
row.status === 'rejected' && row.rejectedAt! > Date.now() - this.cfg.rejectCooldownMs,
|
||||
)
|
||||
)
|
||||
throw new ApiError(
|
||||
429,
|
||||
'REJECTION_COOLDOWN',
|
||||
'상대가 거절했어요. 잠시 후 다시 시도해 주세요.',
|
||||
rejected.rejectedAt! + this.cfg.rejectCooldownMs,
|
||||
'상대가 연속 3회 거절했어요. 잠시 후 다시 시도해 주세요.',
|
||||
decisions[0].rejectedAt! + this.cfg.rejectCooldownMs,
|
||||
'pair',
|
||||
);
|
||||
const active = this.db
|
||||
|
||||
@@ -339,6 +339,11 @@ test('거절, 만료, 취소 후 확정 금지와 거절 재요청 제한', asyn
|
||||
).statusCode,
|
||||
409,
|
||||
);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const next = await f.request(a, b);
|
||||
assert.equal(next.statusCode, 200);
|
||||
await f.call(b, `/transfer-requests/${next.json().requestId}/reject`, 'POST', {});
|
||||
}
|
||||
assert.equal((await f.request(a, b)).statusCode, 429);
|
||||
const c = await f.create();
|
||||
await f.space(a, c);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { fixture } from './helpers.js';
|
||||
import { Limiter, ApiError } from '../src/security.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as schema from '../src/db/schema.js';
|
||||
|
||||
test('a known reconnecting peer returns a transient code without creating a request or charging quotas', async () => {
|
||||
const f = await fixture(true);
|
||||
@@ -100,25 +102,83 @@ test('transfer limit returns Retry-After and cooldown metadata without creating
|
||||
}
|
||||
});
|
||||
|
||||
test('rejection cooldown reports time and does not use a successful-transfer quota', async () => {
|
||||
test('only three consecutive recent rejections trigger a directional pair cooldown; denied retries do not charge quotas', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.cfg.sessionRate = 2;
|
||||
f.cfg.sessionRate = 4;
|
||||
const a = await f.create(),
|
||||
b = await f.create(),
|
||||
c = await f.create();
|
||||
await f.space(a, b);
|
||||
await f.space(a, c);
|
||||
const request = await f.request(a, b);
|
||||
await f.call(b, `/transfer-requests/${request.json().requestId}/reject`, 'POST', {});
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const request = await f.request(a, b);
|
||||
assert.equal(request.statusCode, 200, `rejection ${i} must allow the next request`);
|
||||
const id = request.json().requestId;
|
||||
assert.equal(
|
||||
(await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {})).statusCode,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {})).statusCode,
|
||||
409,
|
||||
);
|
||||
}
|
||||
let deadline;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const denied = await f.request(a, b);
|
||||
assert.equal(denied.statusCode, 429);
|
||||
assert.equal(denied.json().code, 'REJECTION_COOLDOWN');
|
||||
assert.equal(denied.json().limitScope, 'pair');
|
||||
assert(denied.json().retryAfterSeconds <= 300 && denied.json().retryAfterSeconds > 290);
|
||||
deadline ??= denied.json().retryAt;
|
||||
assert.equal(denied.json().retryAt, deadline, 'retry never extends the cooldown');
|
||||
}
|
||||
assert.equal((await f.request(a, c)).statusCode, 200);
|
||||
assert.equal((await f.request(b, a)).statusCode, 200, 'reverse direction is independent');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('acceptance breaks a rejection streak; cancellation does not bypass it; cooldown expires without deleting history', async () => {
|
||||
const f = await fixture(false, undefined, { pairRate: 30, sessionRate: 50 });
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
const reject = async () => {
|
||||
const request = await f.request(a, b);
|
||||
assert.equal(request.statusCode, 200, request.body);
|
||||
const id = request.json().requestId;
|
||||
assert.equal(
|
||||
(await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {})).statusCode,
|
||||
200,
|
||||
);
|
||||
return id;
|
||||
};
|
||||
await reject();
|
||||
await reject();
|
||||
await f.complete(a, b, '수락하면 누적 초기화');
|
||||
await reject();
|
||||
await reject();
|
||||
const cancelled = await f.request(a, b);
|
||||
assert.equal(cancelled.statusCode, 200);
|
||||
await f.call(a, `/transfer-requests/${cancelled.json().requestId}/cancel`, 'POST', {});
|
||||
const id = await reject();
|
||||
assert.equal((await f.request(a, b)).json().code, 'REJECTION_COOLDOWN');
|
||||
const cutoff = Date.now() - f.cfg.rejectCooldownMs;
|
||||
// At exactly the third rejection's deadline the same history no longer blocks.
|
||||
f.service.db
|
||||
.update(schema.requests)
|
||||
.set({ rejectedAt: cutoff })
|
||||
.where(eq(schema.requests.id, id))
|
||||
.run();
|
||||
assert.equal((await f.request(a, b)).statusCode, 200);
|
||||
assert.equal(
|
||||
f.service.db.select().from(schema.requests).where(eq(schema.requests.id, id)).get().status,
|
||||
'rejected',
|
||||
);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { formatSize as size, formatAdminDate as expiryDate } from './displayFormat';
|
||||
import { formatAdminDate as expiryDate } from './displayFormat';
|
||||
import SizeLabel from './SizeLabel.vue';
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
@@ -653,7 +654,7 @@ async function resendVerification() {
|
||||
<FileIcon :size="24" />
|
||||
<div>
|
||||
<strong>{{ item.file.name }}</strong
|
||||
><span>{{ size(item.file.size) }}</span>
|
||||
><span><SizeLabel :bytes="item.file.size" /></span>
|
||||
</div>
|
||||
</div>
|
||||
<pre v-else-if="item.kind === 'text'" class="item-body account-text-preview">{{
|
||||
@@ -724,8 +725,8 @@ async function resendVerification() {
|
||||
</div>
|
||||
<button v-if="nextCursor" class="button load-more" @click="loadMoreAccount">더 보기</button>
|
||||
<p class="scope-note">
|
||||
내 보관함 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }} · 항목별 최대 7일
|
||||
보관
|
||||
내 보관함 <SizeLabel :bytes="storage.usedBytes" /> /
|
||||
<SizeLabel :bytes="storage.limitBytes" /> · 항목별 최대 7일 보관
|
||||
</p>
|
||||
</section>
|
||||
<div v-if="compose" class="modal-backdrop" @click.self="!saveTransfer && (compose = false)">
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
X,
|
||||
} from '@lucide/vue';
|
||||
import { api, showError } from './client';
|
||||
import { formatSize as size, formatAdminDate as date, formatCountry } from './displayFormat';
|
||||
import { formatAdminDate as date, formatCountry } from './displayFormat';
|
||||
import SizeLabel from './SizeLabel.vue';
|
||||
import NetworkPanel from './NetworkPanel.vue';
|
||||
import CapacityPanel from './CapacityPanel.vue';
|
||||
import AdminAudit from './AdminAudit.vue';
|
||||
@@ -147,14 +148,14 @@ onMounted(async () => {
|
||||
<article>
|
||||
<Folder :size="21" /><span>저장 파일</span><strong>{{ overview.storedFiles }}개</strong
|
||||
><small
|
||||
>{{ size(overview.storedBytes) }} /
|
||||
{{ size(overview.settings.totalStorageLimitMb * 1048576) }}</small
|
||||
>
|
||||
><SizeLabel :bytes="overview.storedBytes" /> /
|
||||
<SizeLabel :bytes="overview.settings.totalStorageLimitMb * 1048576"
|
||||
/></small>
|
||||
</article>
|
||||
<article>
|
||||
<HardDrive :size="21" /><span>디스크 여유</span
|
||||
><strong>{{ size(overview.diskFreeBytes) }}</strong
|
||||
><small>업로드 예약 {{ size(overview.reservedBytes) }}</small>
|
||||
><strong><SizeLabel :bytes="overview.diskFreeBytes" /></strong
|
||||
><small>업로드 예약 <SizeLabel :bytes="overview.reservedBytes" /></small>
|
||||
</article>
|
||||
<article>
|
||||
<RefreshCw :size="21" /><span>업로드 처리</span
|
||||
@@ -201,18 +202,22 @@ onMounted(async () => {
|
||||
<article v-for="file in files" :key="file.id" class="admin-file">
|
||||
<div class="admin-file-heading">
|
||||
<strong>{{ file.name }}</strong
|
||||
><span>{{ size(file.size) }}</span>
|
||||
><span><SizeLabel :bytes="file.size" /></span>
|
||||
</div>
|
||||
<div class="admin-file-info admin-file-owner">
|
||||
<code>{{
|
||||
file.uploader?.id || file.path.split('/')[0].replace(/^(user|guest)-/, '')
|
||||
}}</code>
|
||||
<span>{{
|
||||
file.uploader?.kind === 'user' ? file.uploader.email || '삭제된 계정' : '비로그인'
|
||||
<span aria-hidden="true">·</span
|
||||
><span>{{
|
||||
file.uploader?.kind === 'user'
|
||||
? file.uploader.email || '삭제된 계정'
|
||||
: '게스트(Guest)'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="admin-file-info">
|
||||
<Globe :size="15" /><span>접속 지역 · {{ region(file.lastLocation) }}</span
|
||||
><span aria-hidden="true">·</span
|
||||
><code>{{ file.lastLocation?.ip || 'IP 확인 불가' }}</code>
|
||||
</div>
|
||||
<footer>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { formatSize as size } from './displayFormat';
|
||||
import SizeLabel from './SizeLabel.vue';
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
error,
|
||||
transferCooldown,
|
||||
transferConnecting,
|
||||
latestRequestedTransfer,
|
||||
waitForConnection,
|
||||
requestTransfer,
|
||||
bootstrap,
|
||||
@@ -436,8 +437,25 @@ async function closeSend() {
|
||||
itemToSend.value = null;
|
||||
}
|
||||
}
|
||||
watch(requests, (current, previous) => {
|
||||
for (const request of current)
|
||||
const notifiedRejections = new Set();
|
||||
watch([requests, latestRequestedTransfer], ([current, requestedId], [previous]) => {
|
||||
const before = new Map(previous.map((request) => [request.id, request]));
|
||||
for (const request of current) {
|
||||
if (
|
||||
request.direction === 'sent' &&
|
||||
request.status === 'rejected' &&
|
||||
!notifiedRejections.has(request.id) &&
|
||||
(request.id === requestedId || before.get(request.id)?.status === 'pending')
|
||||
) {
|
||||
notifiedRejections.add(request.id);
|
||||
if (request.recipientDevice?.id === selected.value?.id) {
|
||||
sendOpen.value = false;
|
||||
selected.value = null;
|
||||
itemToSend.value = null;
|
||||
forwardedItem.value = null;
|
||||
}
|
||||
notify(`${request.recipientDevice?.name || '상대방'}에서 전송을 거절했어요.`);
|
||||
}
|
||||
if (
|
||||
request.direction === 'sent' &&
|
||||
request.transferStatus === 'stored' &&
|
||||
@@ -449,6 +467,9 @@ watch(requests, (current, previous) => {
|
||||
itemToSend.value = null;
|
||||
notify('전송했습니다.');
|
||||
}
|
||||
}
|
||||
const visible = new Set(current.map((request) => request.id));
|
||||
for (const id of notifiedRejections) if (!visible.has(id)) notifiedRejections.delete(id);
|
||||
});
|
||||
const selectionEpoch = ref(0);
|
||||
watch(spaces, (current, previous) => {
|
||||
@@ -541,12 +562,16 @@ async function forwardVault(item) {
|
||||
await act(async () => {
|
||||
const target = selected.value;
|
||||
await prepareTransfer(target);
|
||||
await requestTransfer(`/vault/${item.accessId}/send`, { targetDeviceId: target.id });
|
||||
const { requestId } = await requestTransfer(`/vault/${item.accessId}/send`, {
|
||||
targetDeviceId: target.id,
|
||||
});
|
||||
await refresh();
|
||||
if (account.value?.id !== owner) return;
|
||||
const request = requests.value.find((request) => request.id === requestId);
|
||||
if (request?.status === 'rejected' || request?.transferStatus === 'stored') return;
|
||||
forwardedItem.value = item;
|
||||
sendOpen.value = true;
|
||||
itemToSend.value = null;
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
watch(
|
||||
@@ -985,7 +1010,9 @@ async function spaceAction(space, action, memberId) {
|
||||
<p>
|
||||
<template v-if="r.kind === 'file'"
|
||||
>{{ r.count }}개 ·
|
||||
<template v-if="r.count > 1">총 {{ size(r.byteSize) }} · </template></template
|
||||
<template v-if="r.count > 1"
|
||||
>총 <SizeLabel :bytes="r.byteSize" /> ·
|
||||
</template></template
|
||||
>
|
||||
<span
|
||||
>{{
|
||||
@@ -998,7 +1025,7 @@ async function spaceAction(space, action, memberId) {
|
||||
<ul v-if="r.files" class="request-files">
|
||||
<li v-for="file in r.files" :key="file.id">
|
||||
<span class="request-file-name">{{ file.name }}</span
|
||||
><span class="request-file-size">{{ size(file.size) }}</span>
|
||||
><span class="request-file-size"><SizeLabel :bytes="file.size" /></span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { ref, reactive } from 'vue';
|
||||
import { Pencil, RotateCcw } from '@lucide/vue';
|
||||
import { api, showError } from './client';
|
||||
import { formatSize as size, SIZE_GB } from './displayFormat';
|
||||
import { SIZE_GB } from './displayFormat';
|
||||
import SizeLabel from './SizeLabel.vue';
|
||||
const props = defineProps({
|
||||
settings: { type: Object, required: true },
|
||||
defaults: { type: Object, required: true },
|
||||
@@ -158,7 +159,7 @@ async function save() {
|
||||
<div v-for="limit in limits" :key="limit.key">
|
||||
<dt>{{ limit.label }}</dt>
|
||||
<dd>
|
||||
<strong>{{ size(bytes(settings, limit.key)) }}</strong
|
||||
<strong><SizeLabel :bytes="bytes(settings, limit.key)" /></strong
|
||||
><small>{{ limit.hint }}</small>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue';
|
||||
import { Plus, Trash2, X, RefreshCw, Pencil } from '@lucide/vue';
|
||||
import { api, showError } from './client';
|
||||
import {
|
||||
formatAdminDate as date,
|
||||
formatCountry as country,
|
||||
formatSize as size,
|
||||
SIZE_GB,
|
||||
} from './displayFormat';
|
||||
import { formatAdminDate as date, formatCountry as country, SIZE_GB } from './displayFormat';
|
||||
import SizeLabel from './SizeLabel.vue';
|
||||
const props = defineProps({ settings: { type: Object, required: true }, suggestedIp: String });
|
||||
const emit = defineEmits(['saved', 'activity', 'consumed']);
|
||||
const state = ref(props.settings),
|
||||
@@ -391,7 +387,7 @@ function modalKey(event) {
|
||||
</div>
|
||||
<div>
|
||||
<dt>요청한 파일 용량</dt>
|
||||
<dd>{{ size(state.autoUploadBytes) }}</dd>
|
||||
<dd><SizeLabel :bytes="state.autoUploadBytes" /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>자동 차단 시간</dt>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { formatSize as size } from './displayFormat';
|
||||
import SizeLabel from './SizeLabel.vue';
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import {
|
||||
FileUp,
|
||||
@@ -409,7 +409,7 @@ function fileProgress(index) {
|
||||
class="content-feedback"
|
||||
role="status"
|
||||
>
|
||||
텍스트는 최대 {{ size(textLimitBytes) }}까지 보낼 수 있어요.
|
||||
텍스트는 최대 <SizeLabel :bytes="textLimitBytes" />까지 보낼 수 있어요.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="source === 'file'">
|
||||
@@ -427,7 +427,7 @@ function fileProgress(index) {
|
||||
<li v-for="(file, index) in files" :key="index">
|
||||
<div class="selected-file-row">
|
||||
<FileIcon :size="15" /><span>{{ file.name }}</span
|
||||
><small>{{ size(file.size) }}</small
|
||||
><small><SizeLabel :bytes="file.size" /></small
|
||||
><button
|
||||
class="icon-button"
|
||||
:disabled="!mutable"
|
||||
|
||||
38
apps/web/src/SizeLabel.vue
Normal file
38
apps/web/src/SizeLabel.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { formatSize } from './displayFormat';
|
||||
const props = defineProps({ bytes: { type: Number, required: true } });
|
||||
const formatted = computed(() => formatSize(props.bytes));
|
||||
const parts = computed(() => formatted.value.match(/^(.*?)(\.\d+)?(KB|MB|GB)$/));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="file-size"
|
||||
><template v-if="parts"
|
||||
><span class="file-size-whole">{{ parts[1] }}</span
|
||||
><span v-if="parts[2]" class="file-size-fraction">{{ parts[2] }}</span
|
||||
><span class="file-size-unit">{{ parts[3] }}</span></template
|
||||
><template v-else>{{ formatted }}</template></span
|
||||
>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-size,
|
||||
.file-size > span {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.file-size > .file-size-fraction {
|
||||
font-size: 0.75em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.file-size > .file-size-unit {
|
||||
margin-inline-start: 0.15em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
</style>
|
||||
@@ -28,6 +28,8 @@ export const device = ref(null),
|
||||
export const pendingPeers = ref([]);
|
||||
export const transferCooldown = ref({ until: 0, targetDeviceId: null });
|
||||
export const transferConnecting = ref(false);
|
||||
// Also observe a decision that arrives before the first pending snapshot/HTTP response.
|
||||
export const latestRequestedTransfer = ref(null);
|
||||
let pendingTimer;
|
||||
let deviceInfo = {};
|
||||
export const nextCursor = ref(null);
|
||||
@@ -88,7 +90,9 @@ export async function requestTransfer(path, input) {
|
||||
if (device.value?.id !== owner)
|
||||
throw Error('기기 세션이 변경되었습니다. 다시 선택해 주세요.');
|
||||
try {
|
||||
return await api(path, 'POST', input, key);
|
||||
const result = await api(path, 'POST', input, key);
|
||||
latestRequestedTransfer.value = result.requestId;
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Only a known, still-authorized peer in its reconnect grace period is retried.
|
||||
if (error.code !== 'TARGET_RECONNECTING' || Date.now() >= deadline) throw error;
|
||||
@@ -279,8 +283,10 @@ export const refresh = createRefreshQueue(async () => {
|
||||
if (
|
||||
['rejected', 'expired', 'cancelled'].includes(req.status) ||
|
||||
req.transferStatus === 'stored'
|
||||
)
|
||||
) {
|
||||
drafts.delete(req.id);
|
||||
fileDrafts.delete(req.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
export async function send(targetDeviceId, kind, values) {
|
||||
|
||||
Reference in New Issue
Block a user