Move file transfer restriction into network settings

This commit is contained in:
2026-10-15 13:51:36 +09:00
parent 328fad2e36
commit 9cda9cbe84
20 changed files with 456 additions and 88 deletions

View File

@@ -213,6 +213,7 @@ export class Accounts {
this.policy(row.senderUserId!, userId) === 'block'
)
fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.');
if (row.kind === 'file') this.service.admin.assertNewUploads();
if (row.kind === 'file')
this.service.storage.reserve({ id: row.senderUserId!, kind: 'user' }, id);
if (allowSender) this.rule(userId, row.senderUserId!, 'allow');

View File

@@ -224,12 +224,15 @@ export class Administration {
if (this.blocked(req))
fail(403, 'ACCESS_BLOCKED', '이 접속은 서비스 이용이 제한되어 있습니다.');
}
publicPolicy() {
return { fileTransfersAllowed: !this.state.uploadsPaused, revision: this.state.revision };
}
assertNewUploads() {
if (this.state.uploadsPaused)
throw new ApiError(
503,
'UPLOADS_PAUSED',
'새 파일 업로드를 잠시 중지했습니다. 나중에 다시 시도해 주세요.',
'파일 전송이 일시 제한되어 있어요. 링크와 텍스트는 보낼 수 있어요.',
);
}
admit(req, reply, actor) {
@@ -322,7 +325,9 @@ export class Administration {
)
)
this.automatic.resetCounters();
const filePolicyChanged = next.uploadsPaused !== this.state.uploadsPaused;
this.state = next;
if (filePolicyChanged) this.service.io?.emit('service:policy', this.publicPolicy());
this.compile();
this.disconnectBlocked();
return { ...this.state };

View File

@@ -118,6 +118,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
}
});
io.on('connection', (socket) => {
socket.emit('service:policy', service.admin.publicPolicy());
presence.connect((socket.data.guest as Guest).id, socket, socket.data.ip);
if (socket.data.accountId) {
socket.join(`account:${socket.data.accountId}`);
@@ -478,6 +479,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
device: service.device(guest.id),
csrfToken: service.csrf(token!),
expiresAt: guest.expiresAt,
servicePolicy: service.admin.publicPolicy(),
};
});
app.get('/api/device/snapshot', async (req) => {
@@ -491,6 +493,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
sameNetworkDiscoveryEnabled: cfg.discoveryEnabled,
spaceInviteCodeLength: cfg.spaceInviteCodeLength,
clipboardTextLimitBytes: cfg.maxTextBytes,
servicePolicy: service.admin.publicPolicy(),
locationDiscovery: {
enabled: cfg.locationDiscoveryEnabled,
radiusMeters: cfg.locationRadiusMeters,

View File

@@ -588,7 +588,7 @@ export class Service {
storageUserId?: string,
) {
this.cleanup();
if (input.kind === 'file' && !storedSource) this.admin.assertNewUploads();
if (input.kind === 'file') this.admin.assertNewUploads();
const target = this.deviceGuest(input.targetDeviceId);
if (
target &&
@@ -696,6 +696,7 @@ export class Service {
if (row.recipientGuestId !== g.id) fail(403, 'FORBIDDEN', '수신 기기만 수락할 수 있습니다.');
if (row.status !== 'pending' || row.expiresAt <= Date.now())
fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.');
if (row.kind === 'file') this.admin.assertNewUploads();
const permission = this.locationAcceptances.get(id);
const reserved =
this.cfg.locationDiscoveryEnabled &&

View File

@@ -274,7 +274,7 @@ test('resumable offsets and completion honor the accepted manifest after reducin
'upload-offset': String(offset),
});
assert.equal((await put(0, bytes.subarray(0, CHUNK_BYTES))).statusCode, 200);
await patch(f, admin, { perFileBytes: 1, accountBytes: 1 });
await patch(f, admin, { perFileBytes: 1, accountBytes: 1, uploadsPaused: true });
assert.equal(
(await put(CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).statusCode,
200,
@@ -283,7 +283,7 @@ test('resumable offsets and completion honor the accepted manifest after reducin
const completed = await f.call(user, `/uploads/resumable/${id}/complete`, 'POST', {});
assert.equal(completed.statusCode, 200, completed.body);
assert.equal((await f.call(user, '/account/storage')).json().usedBytes, bytes.length);
assert.equal((await own(f, user)).json().code, 'TRANSFER_LIMIT');
assert.equal((await own(f, user)).json().code, 'UPLOADS_PAUSED');
} finally {
await f.close();
}
@@ -364,3 +364,119 @@ test('offline transfer reset removes only transfer/guest data and keeps accounts
await rm(folder, { recursive: true, force: true });
}
});
test(
'file-only ban publishes a minimal live policy, blocks new uploads, stored-file forwarding and pending acceptance while allowing clipboard and accepted uploads',
{ timeout: 20000 },
async () => {
const f = await fixture();
try {
const admin = await signed(f, 'hello@noname.company'),
a = await f.create(),
b = await f.create(),
c = await f.create(),
d = await f.create();
await f.space(a, b);
await f.space(a, admin);
await f.space(c, d);
const original = await own(f, admin);
assert.equal(
(await upload(f, admin, original.json().requestId, Buffer.from('12345678'))).statusCode,
200,
);
const stored = (await f.call(admin, '/vault')).json().items[0];
const pending = await request(f, a, b),
accepted = await accept(f, d, await request(f, c, d));
assert.equal(pending.statusCode, 200, pending.body);
const event = new Promise((resolve) => a.socket.once('service:policy', resolve));
const settings = await patch(f, admin, { uploadsPaused: true });
assert.deepEqual(await event, { fileTransfersAllowed: false, revision: settings.revision });
const snapshot = (await f.call(a, '/device/snapshot')).json();
assert.deepEqual(snapshot.servicePolicy, {
fileTransfersAllowed: false,
revision: settings.revision,
});
assert.equal('blockedIps' in snapshot.servicePolicy, false);
assert.equal((await request(f, a, b)).json().code, 'UPLOADS_PAUSED');
assert.equal((await own(f, admin)).json().code, 'UPLOADS_PAUSED');
assert.equal(
(
await f.call(
b,
`/transfer-requests/${pending.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).json().code,
'UPLOADS_PAUSED',
);
assert.equal(
(
await f.call(
admin,
`/vault/${stored.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
)
).json().code,
'UPLOADS_PAUSED',
);
assert.deepEqual(
(await f.call(admin, `/access/${stored.accessId}/download`)).rawPayload,
Buffer.from('12345678'),
);
assert.equal((await upload(f, c, accepted, Buffer.from('12345678'))).statusCode, 200);
await f.call(
a,
`/transfer-requests/${pending.json().requestId}/cancel`,
'POST',
{},
randomUUID(),
);
await f.complete(a, b, 'https://example.test/allowed');
for (const body of [
{ kind: 'url', urls: ['https://example.test/clipboard'] },
{ kind: 'text', text: '한글 日本語 😀\n 공백 유지' },
]) {
const saved = await f.call(admin, '/vault', 'POST', body, randomUUID());
assert.equal(saved.statusCode, 200, saved.body);
const item = (await f.call(admin, '/vault'))
.json()
.items.find((item) => item.kind === body.kind);
const forwarded = await f.call(
admin,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
assert.equal(forwarded.statusCode, 200, forwarded.body);
const received = await f.call(
b,
`/transfer-requests/${forwarded.json().requestId}/accept`,
'POST',
{},
randomUUID(),
);
assert.equal(received.statusCode, 200, received.body);
}
const reconnected = new Promise((resolve) => a.socket.once('service:policy', resolve));
a.socket.disconnect();
a.socket.connect();
assert.deepEqual(await reconnected, {
fileTransfersAllowed: false,
revision: settings.revision,
});
await patch(f, admin, { uploadsPaused: false });
assert.equal((await request(f, a, b)).statusCode, 200);
assert.equal(
(await f.call(a, '/device/snapshot')).json().servicePolicy.fileTransfersAllowed,
true,
);
} finally {
await f.close();
}
},
);

View File

@@ -13,6 +13,8 @@ test('shared display sizes use decimal KB below 1MB and switch only at MB/GB bou
[1100000, '1.1MB'],
[1000000000, '1.0GB'],
[1700000000, '1.7GB'],
[7687e9, '7,687.0GB'],
[1234567e9, '1,234,567.0GB'],
])
assert.equal(formatSize(bytes), expected);
});