Feat: 파일 시스템 접근 및 디렉토리 탐색 명령어 구현
- FTPFileSystem 클래스 생성: 파일 시스템 추상화 * 안드로이드 외부 저장소의 FTPServer 디렉토리를 루트로 설정 * 경로 보안 검증 (루트 디렉토리 escape 방지) * 상대/절대 경로 지원 - FTPSession에 파일 시스템 통합 * FTPFileSystem 인스턴스 추가 * PWD 명령어를 파일 시스템 기반으로 수정 - 디렉토리 탐색 명령어 구현 * CWD: 디렉토리 변경 * CDUP: 상위 디렉토리로 이동 - 파일 목록 명령어 구현 * LIST: 상세 파일 목록 (Unix 스타일) * NLST: 파일명만 나열 * 임시로 제어 연결을 통해 전송 (데이터 연결은 Phase 4에서 구현) - 파일/디렉토리 관리 명령어 구현 * MKD: 디렉토리 생성 * RMD: 디렉토리 삭제 * DELE: 파일 삭제 * SIZE: 파일 크기 조회 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
271
app/src/main/java/be/gyu/android/server/ftp/FTPFileSystem.java
Normal file
271
app/src/main/java/be/gyu/android/server/ftp/FTPFileSystem.java
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
package be.gyu.android.server.ftp;
|
||||||
|
|
||||||
|
import android.os.Environment;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
public class FTPFileSystem {
|
||||||
|
private static final String TAG = "FTPFileSystem";
|
||||||
|
|
||||||
|
private final File rootDirectory;
|
||||||
|
private File currentDirectory;
|
||||||
|
|
||||||
|
public FTPFileSystem() {
|
||||||
|
// Use external storage public directory as root
|
||||||
|
// In production, you might want to use app-specific directory or allow user to choose
|
||||||
|
File externalStorage = Environment.getExternalStorageDirectory();
|
||||||
|
this.rootDirectory = new File(externalStorage, "FTPServer");
|
||||||
|
|
||||||
|
// Create root directory if it doesn't exist
|
||||||
|
if (!rootDirectory.exists()) {
|
||||||
|
if (rootDirectory.mkdirs()) {
|
||||||
|
Log.i(TAG, "Root directory created: " + rootDirectory.getAbsolutePath());
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Failed to create root directory, using external storage root");
|
||||||
|
this.rootDirectory = externalStorage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentDirectory = rootDirectory;
|
||||||
|
Log.d(TAG, "File system initialized. Root: " + rootDirectory.getAbsolutePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCurrentPath() {
|
||||||
|
String relativePath = getRelativePath(currentDirectory);
|
||||||
|
return relativePath.isEmpty() ? "/" : "/" + relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean changeDirectory(String path) {
|
||||||
|
File newDir;
|
||||||
|
|
||||||
|
if (path.startsWith("/")) {
|
||||||
|
// Absolute path
|
||||||
|
newDir = new File(rootDirectory, path.substring(1));
|
||||||
|
} else {
|
||||||
|
// Relative path
|
||||||
|
newDir = new File(currentDirectory, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String canonicalPath = newDir.getCanonicalPath();
|
||||||
|
String rootPath = rootDirectory.getCanonicalPath();
|
||||||
|
|
||||||
|
// Security check: prevent escaping root directory
|
||||||
|
if (!canonicalPath.startsWith(rootPath)) {
|
||||||
|
Log.w(TAG, "Attempted to escape root directory: " + canonicalPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newDir.exists() && newDir.isDirectory()) {
|
||||||
|
currentDirectory = newDir;
|
||||||
|
Log.d(TAG, "Changed directory to: " + currentDirectory.getAbsolutePath());
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Directory does not exist: " + newDir.getAbsolutePath());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error changing directory: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean changeToParentDirectory() {
|
||||||
|
File parent = currentDirectory.getParentFile();
|
||||||
|
|
||||||
|
if (parent == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String parentPath = parent.getCanonicalPath();
|
||||||
|
String rootPath = rootDirectory.getCanonicalPath();
|
||||||
|
|
||||||
|
// Cannot go above root directory
|
||||||
|
if (!parentPath.startsWith(rootPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDirectory = parent;
|
||||||
|
Log.d(TAG, "Changed to parent directory: " + currentDirectory.getAbsolutePath());
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error changing to parent directory: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<File> listFiles() {
|
||||||
|
File[] files = currentDirectory.listFiles();
|
||||||
|
List<File> fileList = new ArrayList<>();
|
||||||
|
|
||||||
|
if (files != null) {
|
||||||
|
for (File file : files) {
|
||||||
|
fileList.add(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileList;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String formatFileList(boolean detailed) {
|
||||||
|
List<File> files = listFiles();
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
if (detailed) {
|
||||||
|
// LIST format: Unix-style detailed listing
|
||||||
|
SimpleDateFormat dateFormat = new SimpleDateFormat("MMM dd HH:mm", Locale.US);
|
||||||
|
|
||||||
|
for (File file : files) {
|
||||||
|
sb.append(formatDetailedFile(file, dateFormat)).append("\r\n");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// NLST format: names only
|
||||||
|
for (File file : files) {
|
||||||
|
sb.append(file.getName()).append("\r\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDetailedFile(File file, SimpleDateFormat dateFormat) {
|
||||||
|
// Format: drwxrwxrwx 1 owner group size date name
|
||||||
|
String permissions = file.isDirectory() ? "drwxr-xr-x" : "-rw-r--r--";
|
||||||
|
String owner = "ftp";
|
||||||
|
String group = "ftp";
|
||||||
|
long size = file.length();
|
||||||
|
String date = dateFormat.format(new Date(file.lastModified()));
|
||||||
|
String name = file.getName();
|
||||||
|
|
||||||
|
return String.format("%s %4d %-8s %-8s %10d %s %s",
|
||||||
|
permissions, 1, owner, group, size, date, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean makeDirectory(String dirName) {
|
||||||
|
File newDir = new File(currentDirectory, dirName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String canonicalPath = newDir.getCanonicalPath();
|
||||||
|
String rootPath = rootDirectory.getCanonicalPath();
|
||||||
|
|
||||||
|
if (!canonicalPath.startsWith(rootPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newDir.mkdir()) {
|
||||||
|
Log.i(TAG, "Directory created: " + newDir.getAbsolutePath());
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Failed to create directory: " + newDir.getAbsolutePath());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error creating directory: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean removeDirectory(String dirName) {
|
||||||
|
File dir = new File(currentDirectory, dirName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String canonicalPath = dir.getCanonicalPath();
|
||||||
|
String rootPath = rootDirectory.getCanonicalPath();
|
||||||
|
|
||||||
|
if (!canonicalPath.startsWith(rootPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dir.exists() && dir.isDirectory() && dir.delete()) {
|
||||||
|
Log.i(TAG, "Directory removed: " + dir.getAbsolutePath());
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Failed to remove directory: " + dir.getAbsolutePath());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error removing directory: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean deleteFile(String fileName) {
|
||||||
|
File file = new File(currentDirectory, fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String canonicalPath = file.getCanonicalPath();
|
||||||
|
String rootPath = rootDirectory.getCanonicalPath();
|
||||||
|
|
||||||
|
if (!canonicalPath.startsWith(rootPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.exists() && file.isFile() && file.delete()) {
|
||||||
|
Log.i(TAG, "File deleted: " + file.getAbsolutePath());
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Failed to delete file: " + file.getAbsolutePath());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error deleting file: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public File getFile(String fileName) {
|
||||||
|
File file = new File(currentDirectory, fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String canonicalPath = file.getCanonicalPath();
|
||||||
|
String rootPath = rootDirectory.getCanonicalPath();
|
||||||
|
|
||||||
|
if (!canonicalPath.startsWith(rootPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return file.exists() ? file : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error getting file: " + e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getFileSize(String fileName) {
|
||||||
|
File file = getFile(fileName);
|
||||||
|
return (file != null && file.isFile()) ? file.length() : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getRelativePath(File file) {
|
||||||
|
try {
|
||||||
|
String filePath = file.getCanonicalPath();
|
||||||
|
String rootPath = rootDirectory.getCanonicalPath();
|
||||||
|
|
||||||
|
if (filePath.equals(rootPath)) {
|
||||||
|
return "";
|
||||||
|
} else if (filePath.startsWith(rootPath + File.separator)) {
|
||||||
|
return filePath.substring(rootPath.length() + 1).replace(File.separator, "/");
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error getting relative path: " + e.getMessage());
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public File getRootDirectory() {
|
||||||
|
return rootDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public File getCurrentDirectory() {
|
||||||
|
return currentDirectory;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,11 +18,12 @@ public class FTPSession implements Runnable {
|
|||||||
|
|
||||||
private String username;
|
private String username;
|
||||||
private boolean isAuthenticated = false;
|
private boolean isAuthenticated = false;
|
||||||
private String currentDirectory = "/";
|
|
||||||
private boolean isRunning = true;
|
private boolean isRunning = true;
|
||||||
|
private FTPFileSystem fileSystem;
|
||||||
|
|
||||||
public FTPSession(Socket socket) {
|
public FTPSession(Socket socket) {
|
||||||
this.controlSocket = socket;
|
this.controlSocket = socket;
|
||||||
|
this.fileSystem = new FTPFileSystem();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -73,6 +74,30 @@ public class FTPSession implements Runnable {
|
|||||||
case "PWD":
|
case "PWD":
|
||||||
handlePwd();
|
handlePwd();
|
||||||
break;
|
break;
|
||||||
|
case "CWD":
|
||||||
|
handleCwd(argument);
|
||||||
|
break;
|
||||||
|
case "CDUP":
|
||||||
|
handleCdup();
|
||||||
|
break;
|
||||||
|
case "LIST":
|
||||||
|
handleList(argument);
|
||||||
|
break;
|
||||||
|
case "NLST":
|
||||||
|
handleNlst(argument);
|
||||||
|
break;
|
||||||
|
case "MKD":
|
||||||
|
handleMkd(argument);
|
||||||
|
break;
|
||||||
|
case "RMD":
|
||||||
|
handleRmd(argument);
|
||||||
|
break;
|
||||||
|
case "DELE":
|
||||||
|
handleDele(argument);
|
||||||
|
break;
|
||||||
|
case "SIZE":
|
||||||
|
handleSize(argument);
|
||||||
|
break;
|
||||||
case "NOOP":
|
case "NOOP":
|
||||||
handleNoop();
|
handleNoop();
|
||||||
break;
|
break;
|
||||||
@@ -113,7 +138,144 @@ public class FTPSession implements Runnable {
|
|||||||
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendResponse(FTPResponse.PATHNAME_CREATED, "\"" + currentDirectory + "\" is current directory");
|
String path = fileSystem.getCurrentPath();
|
||||||
|
sendResponse(FTPResponse.PATHNAME_CREATED, "\"" + path + "\" is current directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleCwd(String path) throws IOException {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No directory specified");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileSystem.changeDirectory(path)) {
|
||||||
|
sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory changed to " + fileSystem.getCurrentPath());
|
||||||
|
} else {
|
||||||
|
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to change directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleCdup() throws IOException {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileSystem.changeToParentDirectory()) {
|
||||||
|
sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory changed to " + fileSystem.getCurrentPath());
|
||||||
|
} else {
|
||||||
|
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Already at root directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleList(String path) throws IOException {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, send file list directly without data connection
|
||||||
|
// Phase 4 will implement proper data connection
|
||||||
|
String fileList = fileSystem.formatFileList(true);
|
||||||
|
|
||||||
|
sendResponse(FTPResponse.FILE_STATUS_OK, "File list follows (inline, data connection not yet implemented)");
|
||||||
|
writer.write(fileList);
|
||||||
|
writer.flush();
|
||||||
|
sendResponse(FTPResponse.CLOSING_DATA_CONNECTION, "Directory send OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleNlst(String path) throws IOException {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, send file list directly without data connection
|
||||||
|
String fileList = fileSystem.formatFileList(false);
|
||||||
|
|
||||||
|
sendResponse(FTPResponse.FILE_STATUS_OK, "File list follows (inline, data connection not yet implemented)");
|
||||||
|
writer.write(fileList);
|
||||||
|
writer.flush();
|
||||||
|
sendResponse(FTPResponse.CLOSING_DATA_CONNECTION, "Directory send OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleMkd(String dirName) throws IOException {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirName.isEmpty()) {
|
||||||
|
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No directory name specified");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileSystem.makeDirectory(dirName)) {
|
||||||
|
String newPath = fileSystem.getCurrentPath() + "/" + dirName;
|
||||||
|
sendResponse(FTPResponse.PATHNAME_CREATED, "\"" + newPath + "\" directory created");
|
||||||
|
} else {
|
||||||
|
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to create directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleRmd(String dirName) throws IOException {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirName.isEmpty()) {
|
||||||
|
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No directory name specified");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileSystem.removeDirectory(dirName)) {
|
||||||
|
sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "Directory removed");
|
||||||
|
} else {
|
||||||
|
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to remove directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleDele(String fileName) throws IOException {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileName.isEmpty()) {
|
||||||
|
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No file name specified");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileSystem.deleteFile(fileName)) {
|
||||||
|
sendResponse(FTPResponse.REQUESTED_FILE_ACTION_OK, "File deleted");
|
||||||
|
} else {
|
||||||
|
sendResponse(FTPResponse.FILE_UNAVAILABLE, "Failed to delete file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleSize(String fileName) throws IOException {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileName.isEmpty()) {
|
||||||
|
sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No file name specified");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long size = fileSystem.getFileSize(fileName);
|
||||||
|
if (size >= 0) {
|
||||||
|
sendResponse(FTPResponse.FILE_STATUS, String.valueOf(size));
|
||||||
|
} else {
|
||||||
|
sendResponse(FTPResponse.FILE_UNAVAILABLE, "File not found");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleNoop() throws IOException {
|
private void handleNoop() throws IOException {
|
||||||
|
|||||||
Reference in New Issue
Block a user