Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions KillingPart.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = KillingPart/KillingPart.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 6;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = GQ89YG5G9R;
ENABLE_APP_SANDBOX = YES;
Expand All @@ -459,7 +459,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.5;
MARKETING_VERSION = 1.0.6;
PRODUCT_BUNDLE_IDENTIFIER = com.killingpoint.killingpart;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand All @@ -479,7 +479,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = KillingPart/KillingPart.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 6;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = GQ89YG5G9R;
ENABLE_APP_SANDBOX = YES;
Expand All @@ -504,7 +504,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.5;
MARKETING_VERSION = 1.0.6;
PRODUCT_BUNDLE_IDENTIFIER = com.killingpoint.killingpart;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand Down
2 changes: 2 additions & 0 deletions KillingPart/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SPOTIFY_BASIC_AUTH</key>
<string>$(SPOTIFY_BASIC_AUTH)</string>
Comment on lines +5 to +6
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SPOTIFY_BASIC_AUTH 값을 Info.plist에 포함시키면(빌드 설정 치환이더라도) 최종 앱 번들에 노출되어 역공학으로 추출될 수 있습니다. Spotify client secret/Basic auth를 클라이언트에 포함하지 않도록 구성(백엔드 프록시/PKCE 전환 등)으로 변경하는 게 안전합니다.

Suggested change
<key>SPOTIFY_BASIC_AUTH</key>
<string>$(SPOTIFY_BASIC_AUTH)</string>

Copilot uses AI. Check for mistakes.
<key>BASE_URL</key>
<string>$(BASE_URL)</string>
<key>KAKAO_NATIVE_APP_KEY</key>
Expand Down
56 changes: 56 additions & 0 deletions KillingPart/Models/SpotifyModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation

struct SpotifyTokenResponse: Decodable {
let accessToken: String
let tokenType: String
let expiresIn: Int

enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case tokenType = "token_type"
case expiresIn = "expires_in"
}
}

struct SpotifySearchResponse: Decodable {
let tracks: SpotifyTracks
}

struct SpotifyTracks: Decodable {
let items: [SpotifyTrackItem]
}

struct SpotifyTrackItem: Decodable {
let id: String
let name: String
let artists: [SpotifyArtist]
let album: SpotifyAlbum
}

struct SpotifyArtist: Decodable {
let name: String
}

struct SpotifyAlbum: Decodable {
let id: String
let images: [SpotifyAlbumImage]
}

struct SpotifyAlbumImage: Decodable {
let url: String
let width: Int?
let height: Int?
}

struct SpotifySimpleTrack: Identifiable {
let id: String
let title: String
let artist: String
let albumImageUrl: String?
let albumId: String

var albumImageURL: URL? {
guard let albumImageUrl else { return nil }
return URL(string: albumImageUrl)
}
}
271 changes: 271 additions & 0 deletions KillingPart/Services/SpotifyService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import Foundation

protocol SpotifyServicing {
func searchTracks(query: String, limit: Int) async throws -> [SpotifySimpleTrack]
}

enum SpotifyServiceError: LocalizedError {
case missingBasicAuth
case invalidResponse
case unauthorized
case serverError(statusCode: Int, message: String?)
case decodingFailed
case networkFailure(message: String)

var errorDescription: String? {
switch self {
case .missingBasicAuth:
return "Spotify 인증 설정이 누락되었어요."
case .invalidResponse:
return "Spotify 응답을 확인할 수 없어요."
case .unauthorized:
return "Spotify 인증에 실패했어요. 잠시 후 다시 시도해 주세요."
case .serverError(_, let message):
return message ?? "Spotify 검색 처리에 실패했어요."
case .decodingFailed:
return "Spotify 응답 파싱에 실패했어요."
case .networkFailure(let message):
return message
}
}
}

struct SpotifyService: SpotifyServicing {
private static let tokenCache = SpotifyTokenCache()

private let session: URLSession
private let decoder = JSONDecoder()

init(session: URLSession = .shared) {
self.session = session
}

func searchTracks(query: String, limit: Int = 5) async throws -> [SpotifySimpleTrack] {
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedQuery.isEmpty else { return [] }

let token = try await fetchAccessToken(forceRefresh: false)
do {
return try await searchTracksWithBearerToken(
trimmedQuery,
limit: limit,
bearerToken: token
)
} catch let error as SpotifyServiceError {
guard case .unauthorized = error else {
throw error
}
} catch {
throw mapError(error)
}

let refreshedToken = try await fetchAccessToken(forceRefresh: true)
return try await searchTracksWithBearerToken(
trimmedQuery,
limit: limit,
bearerToken: refreshedToken
)
}

private func searchTracksWithBearerToken(
_ query: String,
limit: Int,
bearerToken: String
) async throws -> [SpotifySimpleTrack] {
var components = URLComponents(string: "https://api.spotify.com/v1/search")
components?.queryItems = [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "type", value: "track"),
URLQueryItem(name: "market", value: "KR"),
URLQueryItem(name: "limit", value: String(limit))
]

guard let url = components?.url else {
throw SpotifyServiceError.invalidResponse
}

var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.get.rawValue
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
request.setValue("ko-KR", forHTTPHeaderField: "Accept-Language")
request.setValue("application/json", forHTTPHeaderField: "Accept")

let (data, response) = try await performDataTask(with: request)
switch response.statusCode {
case 200..<300:
let decoded: SpotifySearchResponse
do {
decoded = try decoder.decode(SpotifySearchResponse.self, from: data)
} catch {
throw SpotifyServiceError.decodingFailed
}

return decoded.tracks.items.map { item in
let artistNames = item.artists.map(\.name).joined(separator: ", ")

return SpotifySimpleTrack(
id: item.id,
title: item.name,
artist: artistNames.isEmpty ? "Unknown Artist" : artistNames,
albumImageUrl: item.album.images.sorted { (lhs, rhs) in
(lhs.width ?? 0) > (rhs.width ?? 0)
}.first?.url,
Comment on lines +110 to +112
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앨범 이미지 선택을 위해 images.sorted { ... }.first로 매번 전체 정렬을 수행하고 있는데, 여기서는 가장 큰 width 1개만 필요하므로 max(by:) 등으로 최대값만 선택하면 불필요한 정렬 비용을 줄일 수 있습니다 (limit을 늘릴 경우에도 유리).

Suggested change
albumImageUrl: item.album.images.sorted { (lhs, rhs) in
(lhs.width ?? 0) > (rhs.width ?? 0)
}.first?.url,
albumImageUrl: item.album.images.max { (lhs, rhs) in
(lhs.width ?? 0) < (rhs.width ?? 0)
}?.url,

Copilot uses AI. Check for mistakes.
albumId: item.album.id
)
}
case 401:
await Self.tokenCache.clear()
throw SpotifyServiceError.unauthorized
default:
throw SpotifyServiceError.serverError(
statusCode: response.statusCode,
message: responseMessage(from: data)
)
}
}

private func fetchAccessToken(forceRefresh: Bool) async throws -> String {
if !forceRefresh, let cachedToken = await Self.tokenCache.validToken() {
return cachedToken
}

let basicAuth = try spotifyBasicAuth()
guard let url = URL(string: "https://accounts.spotify.com/api/token") else {
throw SpotifyServiceError.invalidResponse
}

var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.post.rawValue
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(basicAuth, forHTTPHeaderField: "Authorization")
request.httpBody = "grant_type=client_credentials".data(using: .utf8)

let (data, response) = try await performDataTask(with: request)
switch response.statusCode {
case 200..<300:
let decoded: SpotifyTokenResponse
do {
decoded = try decoder.decode(SpotifyTokenResponse.self, from: data)
} catch {
throw SpotifyServiceError.decodingFailed
}
await Self.tokenCache.save(
token: decoded.accessToken,
expiresIn: decoded.expiresIn
)
return decoded.accessToken
case 401:
await Self.tokenCache.clear()
throw SpotifyServiceError.unauthorized
default:
throw SpotifyServiceError.serverError(
statusCode: response.statusCode,
message: responseMessage(from: data)
)
}
}

private func spotifyBasicAuth() throws -> String {
let rawValue = (Bundle.main.object(forInfoDictionaryKey: "SPOTIFY_BASIC_AUTH") as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !rawValue.isEmpty, rawValue != "$(SPOTIFY_BASIC_AUTH)" else {
throw SpotifyServiceError.missingBasicAuth
}
return rawValue
}
Comment on lines +127 to +176
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 구현은 Spotify Client Credentials 플로우를 위해 SPOTIFY_BASIC_AUTH(클라이언트 ID/Secret 기반 Basic 인증값)를 앱 번들(Info.plist)에서 읽어 토큰을 발급받습니다. 클라이언트 앱에 client secret(또는 그로부터 생성된 Basic auth)을 포함하면 추출이 가능해 보안상 취약하며, Spotify 정책/일반적인 OAuth 보안 관점에서도 권장되지 않습니다. 토큰 발급은 백엔드에서 프록시로 처리하거나, 사용자 인증이 필요하다면 Authorization Code + PKCE 등 secret이 필요 없는 플로우로 전환하는 방향으로 수정해 주세요.

Copilot uses AI. Check for mistakes.

private func performDataTask(with request: URLRequest) async throws -> (Data, HTTPURLResponse) {
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SpotifyServiceError.invalidResponse
}
return (data, httpResponse)
} catch {
throw mapError(error)
}
}

private func mapError(_ error: Error) -> SpotifyServiceError {
if let spotifyError = error as? SpotifyServiceError {
return spotifyError
}
return .networkFailure(message: "Spotify 요청 중 네트워크 오류가 발생했어요.")
}

private func responseMessage(from data: Data) -> String? {
guard !data.isEmpty else { return nil }

if
let decodedError = try? decoder.decode(SpotifyTopLevelErrorResponse.self, from: data),
let message = decodedError.error?.message?.trimmingCharacters(in: .whitespacesAndNewlines),
!message.isEmpty
{
return message
}

if
let tokenError = try? decoder.decode(SpotifyTokenErrorResponse.self, from: data),
let description = tokenError.errorDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
!description.isEmpty
{
return description
}

if
let raw = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty
{
return raw
}

return nil
}
}

private struct SpotifyTopLevelErrorResponse: Decodable {
let error: SpotifyMessageError?
}

private struct SpotifyMessageError: Decodable {
let status: Int?
let message: String?
}

private struct SpotifyTokenErrorResponse: Decodable {
let error: String?
let errorDescription: String?

enum CodingKeys: String, CodingKey {
case error
case errorDescription = "error_description"
}
}

private actor SpotifyTokenCache {
private var accessToken: String?
private var expirationDate: Date?

func validToken() -> String? {
guard
let accessToken,
let expirationDate,
Date() < expirationDate
else {
return nil
}
return accessToken
}

func save(token: String, expiresIn: Int) {
accessToken = token
expirationDate = Date().addingTimeInterval(TimeInterval(max(expiresIn - 60, 0)))
}

func clear() {
accessToken = nil
expirationDate = nil
}
}
Loading