Compare commits

..
14 Commits
Author SHA1 Message Date
puttaalu 43075f00d4 Merge pull request 'Add /manage (edit, delete) and -default-view' (#1) from manage-endpoint into trunk-ng
Reviewed-on: #1
2026-10-03 11:15:40 +00:00
foxandClaude Fable 5.1 3f719359de feat(manage): add /manage pages to edit and delete photos, behind -manage
/manage lists every photo; /manage/edit/{slug} rewrites the photo's
.toml sidecar (caption, date), /manage/delete/{slug} asks for
confirmation and then removes the image, its sidecar and everything
generated from them. Builds now drop day pages of days without photos
and pager pages past the last one, so a delete or a date change leaves
nothing behind.

The pages are off by default: the routes are only registered with
-manage, so an install that does not opt in has no such code path and
answers 404. NixOS gets services.photogallery.manage.enable, which
also adds the nginx location only when on. Like /upload, the pages
carry no auth of their own and are not linked from the public site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 08:17:17 +02:00
foxandClaude Fable 5.1 f1abd45f24 feat(generator): add -default-view to choose timeline or grid as site root
The chosen view is published at /, the other under /timeline/ or
/grid/. Page paths, pager links and the cross-links between the views
are derived from the setting. The directory named after the default
view is removed on build, since it can only be a leftover from before
a switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 08:16:50 +02:00
puttaalu f33bdd55b1 Relicense to the public domain (Unlicense)
Release the project into the public domain instead of MIT.
2026-09-26 14:06:20 +02:00
puttaalu 91fbf4f93b Name the desktop binary photog
The CMake target stays 'uploader' but the output binary is now 'photog',
matching the package and app, so 'nix run' and the desktop entry resolve
it (nix run looked for bin/photog and failed on bin/uploader). Also set
meta.mainProgram.
2026-09-26 10:51:39 +02:00
puttaalu 17222f7120 Make the desktop flake build on macOS
Wayland is Linux-only and refuses to evaluate on darwin, so qtwayland is
now added only on Linux (macOS uses the Cocoa QPA plugin in qtbase). The
package, dev shell, and plugin search path all build from one platform-
guarded module list. Evaluates on aarch64-darwin; Linux is unchanged.
2026-09-26 10:44:23 +02:00
puttaalu 591eaed6a3 Add app icons across web, Android, and the desktop app
- Web: favicon.ico + png icons under static/, served from the site root
  and linked from every page head.
- Android: the artwork becomes the launcher icon — the 432px adaptive
  foreground over the gruvbox background, replacing the placeholder vector
  drawables.
- Desktop: the icon is the Qt window/taskbar icon (embedded via app.qrc),
  and the app is now an installable package — a wrapped binary, a
  photog.desktop entry, and hicolor icons — so `nix profile install
  ./desktop` puts "Photog" in the application menu.
2026-09-26 10:33:22 +02:00
puttaalu a7783671f2 Add bulk multi-photo upload to the desktop app
Mirror the Android bulk feature in the Qt uploader: choose several photos
at once, each a card with its own caption, date, and upload status, and
post the batch.

- Per-photo cards (PhotoItemWidget) in a scrollable list; retry re-posts
  only the failures.
- One upload per photo with a per-item UUID slug suffix so a batch posted
  in the same second cannot collide.
- Image prep runs off the UI thread via QtConcurrent so the window stays
  responsive.
- A "Simultaneous uploads" setting (1–8, default 3) caps how many photos
  are prepared/uploaded at once; the rest queue and fill freed slots.
2026-09-26 10:17:18 +02:00
puttaalu 1d81ec4511 Add a source-code link to the gallery header
Next to the RSS feed link on the timeline and grid headers, pointing at
the public repo so visitors can find the source.
2026-09-26 10:17:18 +02:00
puttaalu dc47619937 Rename the Android app to Photog 2026-09-26 10:17:18 +02:00
puttaalu c404aac66c add icons 2026-09-26 10:16:17 +02:00
puttaalu 23e14f5aa8 Merge desktop-qt-uploader: Qt6 desktop uploader 2026-08-27 08:44:35 +02:00
puttaalu 6af0eb115c Add multi-photo bulk upload to the Android app
Pick or share several photos at once and post them in one batch, still
against the existing single-image /upload endpoint — no server change.

- Unify the post screen on a list model (PickedItem): a single-photo post
  or share is just a batch of one, so there is one code path.
- Multi-select via PickMultipleVisualMedia and multi-share via
  ACTION_SEND_MULTIPLE (manifest filter added).
- Per-photo caption and EXIF-prefilled date; each card shows its own
  upload status, with a summary and retry-only-failures on re-post.
- Enqueue one WorkManager worker per photo, reusing the durable retry
  queue. UploadQueue now appends a per-item suffix to the slug so a burst
  enqueued in the same second cannot collide and be dropped by
  enqueueUniqueWork.
2026-08-27 08:43:24 +02:00
puttaalu d07e3eb0dc Add Qt6 desktop uploader
A native Qt6/C++ desktop client for the gallery's POST /upload API, in
desktop/ with its own flake (kept separate from the Go server module the
same way android/ is). Single-photo parity with the Android app:

- multipart upload with preemptive HTTP Basic auth and retry-aware outcome
  classification (uploadclient)
- JPEG/PNG metadata stripped byte-for-byte to drop embedded GPS, EXIF
  orientation baked in, and a quality/scale valve for the 20M nginx cap;
  HEIC decodes via the qtimageformats plugin (imageprep)
- base URL / username in QSettings, password in the system keychain via
  qtkeychain; https required and a trailing /upload stripped (settings)
- settings and post screens in a QStackedWidget (mainwindow)

Native Wayland or xcb is selected at runtime. Bulk upload, EXIF
capture-date prefill, and moving image prep off the UI thread are left as
follow-ups.
2026-08-26 00:28:35 +02:00
85 changed files with 1873 additions and 262 deletions
+2 -2
View File
@@ -19,13 +19,13 @@ The binary does three things at once:
- Serves a multipart upload API at `POST /upload`
- In dev mode (`--serve`), also serves `public/` as static files
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `GET`/`POST /manage/delete/{slug}` (confirm, then delete), `GET /health`.
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`, and with `-manage`: `GET /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `GET`/`POST /manage/delete/{slug}` (confirm, then delete).
In production nginx sits in front: serves `public/` directly, proxies `/upload` and `/manage` to localhost, htpasswd on both. Note `/health` is **not** proxied — nginx only forwards `location /upload` and `location /manage`, so anything outside the box can only reach those two.
### Managing photos
`manage.go` holds the `/manage` handlers, kept apart from the upload code in `server.go`. The pages are rendered on request from the sidecars (not generated into `public/`), and nothing on the public site links to them. Constraints worth knowing before touching it:
`manage.go` holds the `/manage` handlers, kept apart from the upload code in `server.go`. They are only registered when the binary runs with `-manage` (NixOS `services.photogallery.manage.enable`, which also adds the nginx location); without it the paths 404 and there is no edit or delete code path at all. The pages are rendered on request from the sidecars (not generated into `public/`), and nothing on the public site links to them. Constraints worth knowing before touching it:
- An edit only rewrites the `.toml` sidecar. Any key other than `caption` and `date` in a hand-written sidecar is dropped, same as on upload.
- Slugs from the URL are matched against the photos actually in `content/`, never joined into a path.
+20 -17
View File
@@ -1,21 +1,24 @@
MIT License
This is free and unencumbered software released into the public domain.
Copyright (c) 2026 Ashik S
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For more information, please refer to <https://unlicense.org>
+7 -1
View File
@@ -69,6 +69,9 @@ services.photogallery = {
serifFamily = "EB Garamond";
mlFamily = "Manjari";
# Optional: edit and delete published photos at /manage (off by default)
manage.enable = true;
# Optional: built-in nginx vhost
nginx = {
enable = true;
@@ -103,6 +106,7 @@ nix-shell -p apacheHttpd --run \
| `mlFamily` | `Manjari` | CSS font-family for the alt-script bio |
| `pageSize` | `60` | Photos per page on the timeline and grid views |
| `defaultView` | `timeline` | View at the site root, `timeline` or `grid`; the other is served under `/timeline/` or `/grid/` |
| `manage.enable` | `false` | Serve the `/manage` pages (edit, delete) and, with `nginx.enable`, protect them with the same htpasswd |
| `nginx.enable` | `false` | Configure an nginx vhost |
| `nginx.domain` | — | Domain for the vhost |
| `nginx.htpasswdFile` | — | htpasswd file for `/upload` and `/manage` |
@@ -143,7 +147,9 @@ Or open `https://photos.yourdomain.tld/upload` in a browser.
## Editing and deleting photos
Open `https://photos.yourdomain.tld/manage` in a browser. It lists every
The pages are off by default: start the binary with `-manage` (NixOS:
`services.photogallery.manage.enable = true`). Then open
`https://photos.yourdomain.tld/manage` in a browser. It lists every
photo; *Edit* opens a form for the caption and date, and saving rewrites the
photo's `.toml` sidecar, which triggers a rebuild.
+1
View File
@@ -32,6 +32,7 @@
<!-- Share sheet target: pick a photo in Google Photos, share here. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
@@ -39,8 +39,8 @@ class MainActivity : ComponentActivity() {
var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) }
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri -> uri?.let(viewModel::selectImage) }
ActivityResultContracts.PickMultipleVisualMedia(),
) { uris -> if (uris.isNotEmpty()) viewModel.selectImages(uris) }
val notificationPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
@@ -73,7 +73,7 @@ class MainActivity : ComponentActivity() {
} else {
PostScreen(
state = viewModel.post,
onPickImage = {
onPickImages = {
picker.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly,
@@ -82,6 +82,7 @@ class MainActivity : ComponentActivity() {
},
onCaptionChange = viewModel::setCaption,
onDateChange = viewModel::setDate,
onRemove = viewModel::removeItem,
onSubmit = viewModel::submit,
onReset = viewModel::clear,
onOpenSettings = { showSettings = true },
@@ -98,16 +99,31 @@ class MainActivity : ComponentActivity() {
consumeShareIntent(intent)
}
/** Picks up a photo handed over by the share sheet. */
/** Picks up one or several photos handed over by the share sheet. */
private fun consumeShareIntent(intent: Intent?) {
if (intent?.action != Intent.ACTION_SEND) return
val uris: List<Uri> = when (intent?.action) {
Intent.ACTION_SEND -> {
val uri: Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
uri?.let(viewModel::selectImage)
listOfNotNull(uri)
}
Intent.ACTION_SEND_MULTIPLE -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
}.orEmpty()
}
else -> return
}
if (uris.isNotEmpty()) viewModel.selectImages(uris)
}
private fun openUrl(url: String) {
@@ -1,17 +1,20 @@
package ws.inflo.photogallery.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker
@@ -36,8 +39,6 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
@@ -48,111 +49,104 @@ private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM
@Composable
fun PostScreen(
state: PostUiState,
onPickImage: () -> Unit,
onCaptionChange: (String) -> Unit,
onDateChange: (LocalDate) -> Unit,
onPickImages: () -> Unit,
onCaptionChange: (id: String, value: String) -> Unit,
onDateChange: (id: String, value: LocalDate) -> Unit,
onRemove: (id: String) -> Unit,
onSubmit: () -> Unit,
onReset: () -> Unit,
onOpenSettings: () -> Unit,
onOpenUrl: (String) -> Unit,
) {
var datePickerOpen by remember { mutableStateOf(false) }
// Which item's date is being edited, if any.
var dateEditFor by remember { mutableStateOf<PickedItem?>(null) }
val pendingCount = state.items.count { it.status !is ItemStatus.Success }
val canPost = state.hasItems && !state.posting && pendingCount > 0
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Post a photo", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
Text("Post photos", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
TextButton(onClick = onOpenSettings) { Text("Settings") }
}
Spacer(Modifier.height(12.dp))
}
item {
OutlinedButton(onClick = onPickImages, modifier = Modifier.fillMaxWidth()) {
Text(if (state.hasItems) "Choose different photos" else "Choose photos")
}
Spacer(Modifier.height(16.dp))
}
if (!state.hasItems) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.height(160.dp)
.background(GruvBg1),
contentAlignment = Alignment.Center,
) {
val preview = state.preview
when {
preview != null -> Image(
bitmap = preview.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
Text("No photos selected", color = GruvFg4)
}
}
}
items(state.items, key = { it.id }) { item ->
ItemCard(
item = item,
onCaptionChange = { onCaptionChange(item.id, it) },
onEditDate = { dateEditFor = item },
onRemove = { onRemove(item.id) },
onOpenUrl = onOpenUrl,
)
state.imageUri != null -> CircularProgressIndicator(color = GruvAqua)
else -> Text("No photo selected", color = GruvFg4)
}
}
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onPickImage, modifier = Modifier.fillMaxWidth()) {
Text(if (state.imageUri == null) "Choose photo" else "Choose a different photo")
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = state.caption,
onValueChange = onCaptionChange,
label = { Text("Caption") },
placeholder = { Text("Defaults to the filename if left blank") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = { datePickerOpen = true },
modifier = Modifier.fillMaxWidth(),
) {
Text("Date: ${state.date.format(DISPLAY_DATE)}")
}
Spacer(Modifier.height(20.dp))
if (state.hasItems) {
item {
Spacer(Modifier.height(8.dp))
Button(
onClick = onSubmit,
enabled = state.imageUri != null && state.status !is PostStatus.Uploading,
enabled = canPost,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.status is PostStatus.Uploading) "Posting…" else "Post")
Text(
when {
state.posting -> "Posting… (${state.succeeded}/${state.items.size})"
pendingCount == 0 -> "All posted"
pendingCount == 1 -> "Post 1 photo"
else -> "Post $pendingCount photos"
},
)
}
Spacer(Modifier.height(16.dp))
when (val status = state.status) {
is PostStatus.Error -> Text(status.message, color = GruvRed)
is PostStatus.Success -> Column {
Text("Posted.", color = GruvAqua)
status.note?.let {
Spacer(Modifier.height(4.dp))
Text("Note: $it", color = GruvFg4, style = MaterialTheme.typography.bodySmall)
if (state.allDone) {
Spacer(Modifier.height(12.dp))
val summary = buildString {
append("${state.succeeded} posted")
if (state.failed > 0) append(", ${state.failed} failed")
}
Text(summary, color = if (state.failed > 0) GruvRed else GruvAqua)
Spacer(Modifier.height(8.dp))
Row {
TextButton(onClick = { onOpenUrl(status.url) }) { Text("Open") }
TextButton(onClick = onReset) { Text("Post another") }
TextButton(onClick = onReset) { Text("Clear") }
}
}
else -> Unit
}
state.lastPost?.let { last ->
item {
Spacer(Modifier.height(24.dp))
HorizontalDivider(color = GruvBg2)
Spacer(Modifier.height(12.dp))
@@ -173,19 +167,96 @@ fun PostScreen(
}
}
}
}
if (datePickerOpen) {
dateEditFor?.let { editing ->
DatePickerModal(
initial = state.date,
onDismiss = { datePickerOpen = false },
initial = editing.date,
onDismiss = { dateEditFor = null },
onConfirm = {
onDateChange(it)
datePickerOpen = false
onDateChange(editing.id, it)
dateEditFor = null
},
)
}
}
@Composable
private fun ItemCard(
item: PickedItem,
onCaptionChange: (String) -> Unit,
onEditDate: () -> Unit,
onRemove: () -> Unit,
onOpenUrl: (String) -> Unit,
) {
Row(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.size(84.dp)
.background(GruvBg1),
contentAlignment = Alignment.Center,
) {
val preview = item.preview
when {
preview != null -> Image(
bitmap = preview.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
else -> CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(24.dp))
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
OutlinedTextField(
value = item.caption,
onValueChange = onCaptionChange,
label = { Text("Caption") },
singleLine = true,
enabled = item.status !is ItemStatus.Uploading,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = onEditDate,
enabled = item.status !is ItemStatus.Uploading,
) {
Text(item.date.format(DISPLAY_DATE))
}
Spacer(Modifier.width(8.dp))
ItemStatusLabel(item.status, onOpenUrl)
}
}
Spacer(Modifier.width(8.dp))
when (item.status) {
is ItemStatus.Uploading ->
CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(20.dp))
is ItemStatus.Success -> Unit
else -> TextButton(onClick = onRemove) { Text("✕", color = GruvFg4) }
}
}
}
@Composable
private fun ItemStatusLabel(status: ItemStatus, onOpenUrl: (String) -> Unit) {
when (status) {
is ItemStatus.Pending -> Unit
is ItemStatus.Uploading -> Text("Uploading…", color = GruvFg4, style = MaterialTheme.typography.bodySmall)
is ItemStatus.Success -> TextButton(onClick = { onOpenUrl(status.url) }) { Text("View") }
is ItemStatus.Error -> Text(
status.message,
color = GruvRed,
style = MaterialTheme.typography.bodySmall,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DatePickerModal(
@@ -24,22 +24,41 @@ import java.io.File
import java.time.LocalDate
import java.util.UUID
sealed interface PostStatus {
data object Idle : PostStatus
data object Uploading : PostStatus
data class Success(val url: String, val note: String?) : PostStatus
data class Error(val message: String) : PostStatus
/** Per-photo status within a batch. */
sealed interface ItemStatus {
data object Pending : ItemStatus
data object Uploading : ItemStatus
data class Success(val url: String, val note: String?) : ItemStatus
data class Error(val message: String) : ItemStatus
}
data class PostUiState(
val imageUri: Uri? = null,
/**
* One photo in the batch. A single-photo post (picker or share) is just a
* batch of one, so the whole screen runs a single code path.
*/
data class PickedItem(
val id: String = UUID.randomUUID().toString(),
val source: Uri,
val preview: Bitmap? = null,
val caption: String = "",
val date: LocalDate = LocalDate.now(),
val status: PostStatus = PostStatus.Idle,
val lastPost: LastPost? = null,
val status: ItemStatus = ItemStatus.Pending,
)
data class PostUiState(
val items: List<PickedItem> = emptyList(),
val lastPost: LastPost? = null,
) {
val posting: Boolean get() = items.any { it.status is ItemStatus.Uploading }
val hasItems: Boolean get() = items.isNotEmpty()
val succeeded: Int get() = items.count { it.status is ItemStatus.Success }
val failed: Int get() = items.count { it.status is ItemStatus.Error }
/** True once every item has reached a terminal state and at least one ran. */
val allDone: Boolean
get() = items.isNotEmpty() &&
items.all { it.status is ItemStatus.Success || it.status is ItemStatus.Error }
}
data class SettingsUiState(
val baseUrl: String = "",
val username: String = "",
@@ -69,57 +88,81 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
// ── Post screen ─────────────────────────────────────────────────────────
fun selectImage(uri: Uri) {
post = post.copy(imageUri = uri, status = PostStatus.Idle, preview = null)
viewModelScope.launch {
/** Replaces the current selection with [uris] and loads each preview. */
fun selectImages(uris: List<Uri>) {
if (uris.isEmpty()) return
val items = uris.map { PickedItem(source = it) }
post = post.copy(items = items)
val context = getApplication<Application>()
val preview = withContext(Dispatchers.IO) { ImagePrep.preview(context, uri) }
val captured = withContext(Dispatchers.IO) { ImagePrep.captureDate(context, uri) }
post = post.copy(preview = preview, date = captured ?: LocalDate.now())
for (item in items) {
viewModelScope.launch {
val preview = withContext(Dispatchers.IO) {
ImagePrep.preview(context, item.source, maxDimension = 256)
}
val captured = withContext(Dispatchers.IO) {
ImagePrep.captureDate(context, item.source)
}
updateItem(item.id) {
it.copy(preview = preview, date = captured ?: it.date)
}
}
}
}
fun setCaption(value: String) {
post = post.copy(caption = value)
fun setCaption(id: String, value: String) {
updateItem(id) { it.copy(caption = value) }
}
fun setDate(value: LocalDate) {
post = post.copy(date = value)
fun setDate(id: String, value: LocalDate) {
updateItem(id) { it.copy(date = value) }
}
fun removeItem(id: String) {
// A photo already uploading keeps its worker; just drop it from the UI.
post = post.copy(items = post.items.filterNot { it.id == id })
}
fun clear() {
post = PostUiState(lastPost = settings.lastPost)
}
/** Uploads every item not already posted. Re-running retries only failures. */
fun submit() {
val uri = post.imageUri ?: return
if (!settings.isConfigured) {
post = post.copy(status = PostStatus.Error("Set the server URL and credentials first"))
post = post.copy(
items = post.items.map {
if (it.status is ItemStatus.Success) it
else it.copy(status = ItemStatus.Error("Set the server URL and credentials first"))
},
)
return
}
post = post.copy(status = PostStatus.Uploading)
val pending = post.items.filter { it.status !is ItemStatus.Success }
if (pending.isEmpty()) return
viewModelScope.launch {
val context = getApplication<Application>()
// Stage the bytes into our own cache before handing the job to
// WorkManager. A content:// URI from the share sheet is only
// readable while the grant lasts, and a retry can fire long after
// the activity that received it is gone.
val staged = withContext(Dispatchers.IO) { stage(uri) }
for (item in pending) {
updateItem(item.id) { it.copy(status = ItemStatus.Uploading) }
viewModelScope.launch {
val staged = withContext(Dispatchers.IO) { stage(item.source) }
if (staged == null) {
post = post.copy(status = PostStatus.Error("Could not read the selected image"))
updateItem(item.id) {
it.copy(status = ItemStatus.Error("Could not read the selected image"))
}
return@launch
}
val id = UploadQueue.enqueue(
val current = post.items.firstOrNull { it.id == item.id } ?: return@launch
val workId = UploadQueue.enqueue(
context = context,
uri = Uri.fromFile(staged),
caption = post.caption,
date = post.date,
caption = current.caption,
date = current.date,
uniqueSuffix = item.id.take(6),
)
observe(id)
observe(item.id, workId)
}
}
}
@@ -136,32 +179,35 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
}
}
private fun observe(id: UUID) {
private fun observe(itemId: String, workId: UUID) {
viewModelScope.launch {
WorkManager.getInstance(getApplication()).getWorkInfoByIdFlow(id).collect { info ->
WorkManager.getInstance(getApplication()).getWorkInfoByIdFlow(workId).collect { info ->
if (info == null) return@collect
when (info.state) {
WorkInfo.State.SUCCEEDED -> {
post = post.copy(
status = PostStatus.Success(
updateItem(itemId) {
it.copy(
status = ItemStatus.Success(
url = info.outputData.getString(UploadWorker.KEY_RESULT_URL).orEmpty(),
note = info.outputData.getString(UploadWorker.KEY_RESULT_NOTE),
),
lastPost = settings.lastPost,
)
}
post = post.copy(lastPost = settings.lastPost)
}
WorkInfo.State.FAILED -> {
post = post.copy(
status = PostStatus.Error(
WorkInfo.State.FAILED -> updateItem(itemId) {
it.copy(
status = ItemStatus.Error(
info.outputData.getString(UploadWorker.KEY_RESULT_ERROR)
?: "Upload failed",
),
)
}
WorkInfo.State.CANCELLED ->
post = post.copy(status = PostStatus.Error("Upload cancelled"))
WorkInfo.State.CANCELLED -> updateItem(itemId) {
it.copy(status = ItemStatus.Error("Upload cancelled"))
}
else -> Unit
}
@@ -169,6 +215,10 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
}
}
private inline fun updateItem(id: String, transform: (PickedItem) -> PickedItem) {
post = post.copy(items = post.items.map { if (it.id == id) transform(it) else it })
}
// ── Settings screen ─────────────────────────────────────────────────────
fun setBaseUrl(value: String) {
@@ -18,9 +18,11 @@ object UploadQueue {
/**
* Slug format. Uses the enqueue wall-clock time rather than the photo's
* capture time: a burst of frames shot in the same second would collide on
* capture time, whereas posts are made one at a time. The real capture date
* still travels in the `date` field and lands in the .toml sidecar.
* capture time: capture time collides across a burst. A [uniqueSuffix] is
* appended so that a bulk post — several photos enqueued in the same second
* — cannot collide either; without it enqueueUniqueWork would silently drop
* the duplicates. The real capture date still travels in the `date` field
* and lands in the .toml sidecar.
*/
private val SLUG_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss")
@@ -32,8 +34,9 @@ object UploadQueue {
uri: Uri,
caption: String,
date: LocalDate,
uniqueSuffix: String,
): UUID {
val slug = LocalDateTime.now().format(SLUG_FORMAT)
val slug = LocalDateTime.now().format(SLUG_FORMAT) + "-" + uniqueSuffix
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1d2021"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Aperture blades in Gruvbox aqua on the adaptive-icon 108dp canvas.
The safe zone is the middle 72dp, so the art is inset accordingly. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#8ec07c"
android:pathData="M54,30 A24,24 0 0,1 78,54 L54,54 Z" />
<path
android:fillColor="#689d6a"
android:pathData="M78,54 A24,24 0 0,1 54,78 L54,54 Z" />
<path
android:fillColor="#8ec07c"
android:pathData="M54,78 A24,24 0 0,1 30,54 L54,54 Z" />
<path
android:fillColor="#689d6a"
android:pathData="M30,54 A24,24 0 0,1 54,30 L54,54 Z" />
<path
android:strokeColor="#ebdbb2"
android:strokeWidth="3"
android:fillColor="#00000000"
android:pathData="M54,28 A26,26 0 1,1 53.99,28 Z" />
</vector>
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- minSdk is 28, so every device resolves the v26 adaptive icon and no
density-bucket PNG fallbacks are needed. -->
density-bucket PNG fallbacks are needed. The foreground is the full circular
app icon (ic_launcher_foreground.png); the launcher mask crops the disc to
its shape, and the solid gruvbox background fills the transparent corners. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@color/gruvbox_bg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@color/gruvbox_bg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Gallery Upload</string>
<string name="app_name">Photog</string>
<string name="upload_channel_name">Uploads</string>
</resources>
+3
View File
@@ -0,0 +1,3 @@
/build/
/.direnv/
.envrc
+42
View File
@@ -0,0 +1,42 @@
cmake_minimum_required(VERSION 3.20)
project(photogallery_uploader LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
find_package(Qt6 REQUIRED COMPONENTS Widgets Network Concurrent)
find_package(Qt6Keychain REQUIRED)
add_executable(uploader
main.cpp
mainwindow.cpp
photoitemwidget.cpp
settings.cpp
uploadclient.cpp
imageprep.cpp
app.qrc
)
target_link_libraries(uploader PRIVATE
Qt6::Widgets
Qt6::Network
Qt6::Concurrent
Qt6Keychain::Qt6Keychain
)
# The binary is "photog" (matching the app + package), so `nix run` and the
# desktop entry find it; the CMake target stays "uploader".
set_target_properties(uploader PROPERTIES OUTPUT_NAME photog)
# Installation — the binary, a desktop entry, and the app icon in the hicolor
# theme, so `nix profile install ./desktop` puts "Photog" in the app menu.
include(GNUInstallDirs)
install(TARGETS uploader RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(FILES photog.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
foreach(sz 32 128 256 512)
install(FILES icons/app-${sz}.png
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/${sz}x${sz}/apps
RENAME photog.png)
endforeach()
+9
View File
@@ -0,0 +1,9 @@
<!DOCTYPE RCC>
<RCC version="1.0">
<qresource prefix="/">
<file>icons/app-32.png</file>
<file>icons/app-128.png</file>
<file>icons/app-256.png</file>
<file>icons/app-512.png</file>
</qresource>
</RCC>
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1787498568,
"narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+55
View File
@@ -0,0 +1,55 @@
{
description = "photogallery uploader — Qt6 C++ desktop app";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
inherit (pkgs) lib stdenv;
# Wayland is Linux-only; macOS uses the Cocoa QPA plugin inside qtbase,
# so qtwayland is added only on Linux (it refuses to evaluate on darwin).
qtModules = [ pkgs.qt6.qtbase pkgs.qt6.qtimageformats ]
++ lib.optionals stdenv.hostPlatform.isLinux [ pkgs.qt6.qtwayland ];
qtPlugins = lib.makeSearchPathOutput "out" "lib/qt-6/plugins" qtModules;
in {
# Installable build: `nix profile install ./desktop` drops "Photog" into
# the application menu with its icon. wrapQtAppsHook wraps the binary so
# it finds the Qt platform (Cocoa on macOS; xcb + wayland on Linux) and
# image-format plugins on its own, without the dev shell.
packages.default = stdenv.mkDerivation {
pname = "photog";
version = "0.1.0";
src = ./.;
nativeBuildInputs = with pkgs; [
cmake
ninja
pkg-config
qt6.wrapQtAppsHook
];
buildInputs = [ pkgs.qt6Packages.qtkeychain ] ++ qtModules;
meta.mainProgram = "photog";
};
devShells.default = pkgs.mkShell {
packages = with pkgs; [
cmake
ninja
pkg-config
qt6Packages.qtkeychain # password → Keychain (macOS) / libsecret / kwallet
] ++ qtModules;
shellHook = ''
# Let a binary run straight from the build dir find Qt's platform and
# image-format plugins without wrapQtAppsHook.
export QT_PLUGIN_PATH="${qtPlugins}''${QT_PLUGIN_PATH:+:$QT_PLUGIN_PATH}"
'';
};
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+237
View File
@@ -0,0 +1,237 @@
#include "imageprep.h"
#include <QBuffer>
#include <QDir>
#include <QFile>
#include <QImage>
#include <QImageReader>
#include <QImageWriter>
namespace {
// nginx is configured with client_max_body_size 20M; leave headroom.
constexpr qint64 kMaxUploadBytes = 18LL * 1024 * 1024;
constexpr int kJpegQuality = 92;
const int kQualityLadder[] = {92, 85, 75};
// Encodes to JPEG in memory at the given quality.
QByteArray encodeJpeg(const QImage &image, int quality)
{
QByteArray out;
QBuffer buffer(&out);
buffer.open(QIODevice::WriteOnly);
QImageWriter writer(&buffer, "jpeg");
writer.setQuality(quality);
writer.write(image);
return out;
}
// Decodes with EXIF orientation baked into the pixels. Works for HEIC too, via
// the qtimageformats plugin.
QImage decodeOriented(const QString &path)
{
QImageReader reader(path);
reader.setAutoTransform(true);
return reader.read();
}
} // namespace
ImagePrep::Prepared ImagePrep::prepare(const QString &sourcePath, const QString &cacheDir,
const QString &baseName)
{
Prepared result;
QFile in(sourcePath);
if (!in.open(QIODevice::ReadOnly)) {
result.error = QStringLiteral("Could not read the selected image");
return result;
}
const QByteArray source = in.readAll();
in.close();
QStringList notes;
// A rotation flag cannot survive metadata stripping, so bake it into the
// pixels instead, which costs a re-encode.
QImageReader orientationReader(sourcePath);
const bool mustRotate =
orientationReader.transformation() != QImageIOHandler::TransformationNone;
QByteArray bytes;
QString extension;
if (!mustRotate && isJpeg(source)) {
bytes = stripJpegSegments(source);
extension = QStringLiteral("jpg");
} else if (!mustRotate && isPng(source)) {
bytes = stripPngChunks(source);
extension = QStringLiteral("png");
}
if (bytes.isEmpty()) {
// Re-encode path: rotated originals, HEIC, or a JPEG/PNG whose marker
// structure did not parse.
const QImage image = decodeOriented(sourcePath);
if (image.isNull()) {
result.error = QStringLiteral("Could not decode the selected image");
return result;
}
if (mustRotate)
notes << QStringLiteral("rotated to match its EXIF orientation");
else if (!isJpeg(source) && !isPng(source))
notes << QStringLiteral("converted to JPEG");
bytes = encodeJpeg(image, kJpegQuality);
extension = QStringLiteral("jpg");
}
// Size valve. Only trips on genuinely huge originals; quality is stepped
// before touching resolution.
if (bytes.size() > kMaxUploadBytes) {
QImage image = decodeOriented(sourcePath);
if (image.isNull()) {
result.error = QStringLiteral("Could not decode the selected image");
return result;
}
QByteArray shrunk;
for (int quality : kQualityLadder) {
const QByteArray attempt = encodeJpeg(image, quality);
if (attempt.size() <= kMaxUploadBytes) {
shrunk = attempt;
notes << QStringLiteral("re-encoded at quality %1 to fit the 20M upload limit").arg(quality);
break;
}
}
if (shrunk.isEmpty()) {
double factor = 1.0;
while (factor > 0.2) {
factor *= 0.75;
const QImage scaled = image.scaledToWidth(
qMax(1, int(image.width() * factor)), Qt::SmoothTransformation);
const QByteArray attempt = encodeJpeg(scaled, kQualityLadder[2]);
if (attempt.size() <= kMaxUploadBytes) {
shrunk = attempt;
notes << QStringLiteral("scaled to %1×%2 to fit the 20M upload limit")
.arg(scaled.width()).arg(scaled.height());
break;
}
}
}
if (shrunk.isEmpty()) {
result.error = QStringLiteral("Image is too large to upload even after downscaling");
return result;
}
bytes = shrunk;
extension = QStringLiteral("jpg");
}
const QString outPath = QDir(cacheDir).filePath(baseName + QLatin1Char('.') + extension);
QFile out(outPath);
if (!out.open(QIODevice::WriteOnly) || out.write(bytes) != bytes.size()) {
result.error = QStringLiteral("Could not write the prepared image");
return result;
}
out.close();
result.filePath = outPath;
result.note = notes.join(QStringLiteral("; "));
return result;
}
bool ImagePrep::isJpeg(const QByteArray &b)
{
return b.size() > 3 && quint8(b[0]) == 0xFF && quint8(b[1]) == 0xD8 && quint8(b[2]) == 0xFF;
}
bool ImagePrep::isPng(const QByteArray &b)
{
return b.size() > 8 && quint8(b[0]) == 0x89 && quint8(b[1]) == 0x50 && quint8(b[2]) == 0x4E
&& quint8(b[3]) == 0x47;
}
// Drops APP1 (EXIF and XMP) and COM segments, keeping the compressed scan data
// byte-for-byte. APP0/JFIF and APP2/ICC stay: removing the colour profile would
// shift the rendered colours. Returns an empty array if the marker structure
// does not parse, so the caller falls back to a re-encode.
QByteArray ImagePrep::stripJpegSegments(const QByteArray &src)
{
const auto u8 = [&](int i) { return quint8(src[i]); };
QByteArray out;
out.append(char(0xFF));
out.append(char(0xD8));
int i = 2;
while (i + 1 < src.size()) {
if (u8(i) != 0xFF)
return {};
const int marker = u8(i + 1);
// Standalone markers: no length field, no payload.
if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) {
out.append(char(0xFF));
out.append(char(marker));
i += 2;
continue;
}
// Start of scan: entropy-coded data runs to the end of the file.
if (marker == 0xDA) {
out.append(src.mid(i));
return out;
}
if (i + 3 >= src.size())
return {};
const int length = (u8(i + 2) << 8) | u8(i + 3);
if (length < 2 || i + 2 + length > src.size())
return {};
const bool isExifOrXmp = marker == 0xE1;
const bool isComment = marker == 0xFE;
if (!isExifOrXmp && !isComment)
out.append(src.mid(i, 2 + length));
i += 2 + length;
}
return {};
}
// Drops the metadata chunks, keeps everything else including the critical
// IHDR/PLTE/IDAT/IEND run and colour chunks (iCCP, sRGB). Whole chunks go, so no
// CRC needs recomputing.
QByteArray ImagePrep::stripPngChunks(const QByteArray &src)
{
static const QByteArray drop[] = {"eXIf", "tEXt", "zTXt", "iTXt", "tIME"};
const auto u8 = [&](int i) { return quint8(src[i]); };
if (src.size() < 8)
return {};
QByteArray out;
out.append(src.mid(0, 8));
int i = 8;
while (i + 8 <= src.size()) {
const qint64 length =
(qint64(u8(i)) << 24) | (qint64(u8(i + 1)) << 16) | (qint64(u8(i + 2)) << 8) | qint64(u8(i + 3));
if (length < 0)
return {};
const QByteArray type = src.mid(i + 4, 4);
const qint64 total = 12 + length;
if (i + total > src.size())
return {};
bool dropped = false;
for (const QByteArray &d : drop) {
if (type == d) {
dropped = true;
break;
}
}
if (!dropped)
out.append(src.mid(i, int(total)));
i += int(total);
if (type == "IEND")
return out;
}
return {};
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <QByteArray>
#include <QString>
// Turns a picked image into a file the gallery's upload API accepts.
//
// Three things must be true of the result: no EXIF (the gallery publishes the
// uploaded file verbatim as its full-size download, so embedded GPS would be
// public), a .jpg or .png extension (server.go rejects anything else, HEIC
// included), and under nginx's 20M body cap.
//
// JPEG and PNG take a lossless route — whole metadata segments are dropped
// without touching the compressed image data — so the published file keeps the
// original sensor pixels. Anything else, or a file whose EXIF orientation must
// be baked in, is decoded and re-encoded as JPEG (HEIC via Qt's qtimageformats
// plugin). The strippers are a direct port of the Android app's.
class ImagePrep
{
public:
struct Prepared {
QString filePath;
QString note; // non-empty when the app departed from a byte-exact copy
QString error;
bool ok() const { return error.isEmpty(); }
};
// Writes the upload-ready file into cacheDir as baseName.(jpg|png).
static Prepared prepare(const QString &sourcePath, const QString &cacheDir,
const QString &baseName);
private:
static QByteArray stripJpegSegments(const QByteArray &src); // empty on parse failure
static QByteArray stripPngChunks(const QByteArray &src);
static bool isJpeg(const QByteArray &b);
static bool isPng(const QByteArray &b);
};
+25
View File
@@ -0,0 +1,25 @@
#include <QApplication>
#include <QIcon>
#include "mainwindow.h"
int main(int argc, char **argv)
{
QApplication app(argc, argv);
app.setApplicationName(QStringLiteral("photogallery-uploader"));
app.setOrganizationName(QStringLiteral("inflo.ws"));
// Window / taskbar / alt-tab icon, embedded via app.qrc. Several sizes so
// the small titlebar rendering stays crisp rather than downscaling 512px.
QIcon icon;
icon.addFile(QStringLiteral(":/icons/app-32.png"));
icon.addFile(QStringLiteral(":/icons/app-128.png"));
icon.addFile(QStringLiteral(":/icons/app-256.png"));
icon.addFile(QStringLiteral(":/icons/app-512.png"));
app.setWindowIcon(icon);
MainWindow window;
window.resize(440, 680);
window.show();
return app.exec();
}
+361
View File
@@ -0,0 +1,361 @@
#include "mainwindow.h"
#include "imageprep.h"
#include "photoitemwidget.h"
#include "uploadclient.h"
#include <QDateTime>
#include <QFileDialog>
#include <QFileInfo>
#include <QFormLayout>
#include <QFuture>
#include <QFutureWatcher>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QScrollArea>
#include <QSpinBox>
#include <QStackedWidget>
#include <QStandardPaths>
#include <QUuid>
#include <QVBoxLayout>
#include <QtConcurrent>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
{
setWindowTitle(QStringLiteral("Photo Gallery Uploader"));
m_stack = new QStackedWidget(this);
m_stack->addWidget(buildSettingsPage()); // index 0
m_stack->addWidget(buildPostPage()); // index 1
setCentralWidget(m_stack);
if (m_settings.isConfigured())
showPost();
else
showSettings();
}
QWidget *MainWindow::buildSettingsPage()
{
auto *page = new QWidget;
auto *layout = new QVBoxLayout(page);
layout->setContentsMargins(24, 24, 24, 24);
auto *form = new QFormLayout;
m_baseUrl = new QLineEdit;
m_baseUrl->setPlaceholderText(QStringLiteral("https://photos.example.com"));
m_username = new QLineEdit;
m_password = new QLineEdit;
m_password->setEchoMode(QLineEdit::Password);
m_concurrency = new QSpinBox;
m_concurrency->setRange(1, Settings::kMaxConcurrency);
form->addRow(QStringLiteral("Base URL"), m_baseUrl);
form->addRow(QStringLiteral("Username"), m_username);
form->addRow(QStringLiteral("Password"), m_password);
form->addRow(QStringLiteral("Simultaneous uploads"), m_concurrency);
layout->addLayout(form);
m_saveButton = new QPushButton(QStringLiteral("Save && test connection"));
connect(m_saveButton, &QPushButton::clicked, this, &MainWindow::saveAndTest);
layout->addWidget(m_saveButton);
m_settingsStatus = new QLabel;
m_settingsStatus->setWordWrap(true);
layout->addWidget(m_settingsStatus);
m_settingsBack = new QPushButton(QStringLiteral("Back"));
connect(m_settingsBack, &QPushButton::clicked, this, &MainWindow::showPost);
layout->addWidget(m_settingsBack);
layout->addWidget(new QLabel(QStringLiteral(
"The password is stored in your system keychain, never in plain text. "
"https is required — Basic auth would otherwise send it in the clear.")));
layout->addStretch();
return page;
}
QWidget *MainWindow::buildPostPage()
{
auto *page = new QWidget;
auto *outer = new QVBoxLayout(page);
outer->setContentsMargins(24, 24, 24, 24);
auto *choose = new QPushButton(QStringLiteral("Choose photos…"));
connect(choose, &QPushButton::clicked, this, &MainWindow::chooseImages);
outer->addWidget(choose);
// Scrollable list of per-photo cards.
auto *scroll = new QScrollArea;
scroll->setWidgetResizable(true);
auto *container = new QWidget;
m_itemsLayout = new QVBoxLayout(container);
m_itemsLayout->setAlignment(Qt::AlignTop);
m_emptyHint = new QLabel(QStringLiteral("No photos selected"));
m_emptyHint->setAlignment(Qt::AlignCenter);
m_itemsLayout->addWidget(m_emptyHint);
scroll->setWidget(container);
outer->addWidget(scroll, 1);
m_postButton = new QPushButton(QStringLiteral("Post"));
m_postButton->setEnabled(false);
connect(m_postButton, &QPushButton::clicked, this, &MainWindow::submit);
outer->addWidget(m_postButton);
m_postSummary = new QLabel;
m_postSummary->setWordWrap(true);
outer->addWidget(m_postSummary);
m_lastPost = new QLabel;
m_lastPost->setOpenExternalLinks(true);
m_lastPost->setWordWrap(true);
outer->addWidget(m_lastPost);
auto *settingsButton = new QPushButton(QStringLiteral("Settings"));
connect(settingsButton, &QPushButton::clicked, this, &MainWindow::showSettings);
outer->addWidget(settingsButton);
return page;
}
void MainWindow::showSettings()
{
m_baseUrl->setText(m_settings.baseUrl());
m_username->setText(m_settings.username());
m_password->setText(m_settings.readPassword());
m_concurrency->setValue(m_settings.concurrency());
m_settingsStatus->clear();
m_settingsBack->setVisible(m_settings.isConfigured());
m_stack->setCurrentIndex(0);
}
void MainWindow::showPost()
{
const Settings::LastPost last = m_settings.lastPost();
if (last.valid()) {
m_lastPost->setText(QStringLiteral("Last post: <a href=\"%1\">%2</a>")
.arg(last.url, last.caption.isEmpty() ? last.slug : last.caption));
} else {
m_lastPost->clear();
}
m_stack->setCurrentIndex(1);
}
void MainWindow::saveAndTest()
{
// Concurrency is not a credential; persist it regardless of URL validity.
m_settings.setConcurrency(m_concurrency->value());
const QString urlError = Settings::validateBaseUrl(m_baseUrl->text());
if (!urlError.isEmpty()) {
m_settingsStatus->setText(urlError);
return;
}
if (m_username->text().trimmed().isEmpty() || m_password->text().isEmpty()) {
m_settingsStatus->setText(QStringLiteral("Username and password are required"));
return;
}
m_settings.setBaseUrl(m_baseUrl->text());
m_settings.setUsername(m_username->text());
m_settings.writePassword(m_password->text());
m_baseUrl->setText(m_settings.baseUrl());
m_saveButton->setEnabled(false);
m_settingsStatus->setText(QStringLiteral("Testing…"));
auto *client = new UploadClient(m_settings.baseUrl(), m_settings.username(),
m_settings.readPassword(), this);
connect(client, &UploadClient::testFinished, this, [this, client](const QString &error) {
m_saveButton->setEnabled(true);
m_settingsBack->setVisible(m_settings.isConfigured());
m_settingsStatus->setText(error.isEmpty()
? QStringLiteral("Saved — server reachable and credentials accepted")
: error);
client->deleteLater();
});
client->testConnection();
}
void MainWindow::chooseImages()
{
const QStringList paths = QFileDialog::getOpenFileNames(
this, QStringLiteral("Choose photos"), QString(),
QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.heif *.webp *.tif *.tiff *.bmp);;All files (*)"));
for (const QString &path : paths)
addItem(path);
refreshPosting();
}
void MainWindow::addItem(const QString &path)
{
m_emptyHint->hide();
auto *item = new PhotoItemWidget(path);
connect(item, &PhotoItemWidget::removeRequested, this, &MainWindow::removeItem);
m_items.append(item);
m_itemsLayout->addWidget(item);
}
void MainWindow::removeItem(PhotoItemWidget *item)
{
m_items.removeOne(item);
m_queue.removeOne(item); // may be waiting for a slot — drop it before deleting
item->deleteLater();
if (m_items.isEmpty())
m_emptyHint->show();
refreshPosting();
}
void MainWindow::clearItems()
{
for (PhotoItemWidget *item : std::as_const(m_items))
item->deleteLater();
m_items.clear();
m_queue.clear();
m_emptyHint->show();
m_postSummary->clear();
refreshPosting();
}
void MainWindow::submit()
{
if (m_items.isEmpty())
return;
if (!m_settings.isConfigured()) {
m_postSummary->setText(QStringLiteral("Set the server URL and credentials first"));
return;
}
// Capture the batch's credentials once, then feed the queue through a
// concurrency cap rather than firing every photo at the server at once.
m_batchCacheDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
m_batchBaseUrl = m_settings.baseUrl();
m_batchUsername = m_settings.username();
m_batchPassword = m_settings.readPassword();
m_queue.clear();
for (PhotoItemWidget *item : std::as_const(m_items)) {
if (item->status() != PhotoItemWidget::Status::Success)
m_queue.append(item); // re-posting retries only failures
}
if (m_queue.isEmpty())
return;
m_postSummary->clear();
pumpQueue();
refreshPosting();
}
int MainWindow::inFlightCount() const
{
int n = 0;
for (PhotoItemWidget *item : std::as_const(m_items)) {
const PhotoItemWidget::Status s = item->status();
if (s == PhotoItemWidget::Status::Preparing || s == PhotoItemWidget::Status::Uploading)
++n;
}
return n;
}
void MainWindow::pumpQueue()
{
const int cap = m_settings.concurrency();
while (inFlightCount() < cap && !m_queue.isEmpty())
startItem(m_queue.takeFirst());
}
void MainWindow::startItem(PhotoItemWidget *item)
{
item->setStatus(PhotoItemWidget::Status::Preparing);
// Slug from wall-clock time plus a per-item suffix so a batch posted in the
// same second cannot collide (server derives the stored name from the
// uploaded filename). The real capture date rides in the date field.
const QString slug = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-HHmmss"))
+ QLatin1Char('-') + QUuid::createUuid().toString(QUuid::Id128).left(6);
const QString source = item->sourcePath();
const QString cacheDir = m_batchCacheDir;
const QString baseUrl = m_batchBaseUrl;
const QString username = m_batchUsername;
const QString password = m_batchPassword;
auto *watcher = new QFutureWatcher<ImagePrep::Prepared>(this);
connect(watcher, &QFutureWatcher<ImagePrep::Prepared>::finished, this,
[this, watcher, item, baseUrl, username, password]() {
const ImagePrep::Prepared prepared = watcher->result();
watcher->deleteLater();
if (!prepared.ok()) {
item->setStatus(PhotoItemWidget::Status::Error, prepared.error);
pumpQueue(); // free the slot for the next photo
refreshPosting();
return;
}
item->setStatus(PhotoItemWidget::Status::Uploading);
const QString caption = item->caption();
auto *client = new UploadClient(baseUrl, username, password, this);
connect(client, &UploadClient::uploadFinished, this,
[this, client, item, prepared, caption](UploadClient::Result result,
const QString &slug, const QString &url,
const QString &message) {
QFile::remove(prepared.filePath); // no retry queue on the desktop
if (result == UploadClient::Result::Success) {
m_settings.setLastPost({slug, url, caption, QDateTime::currentDateTime()});
item->setStatus(PhotoItemWidget::Status::Success, {}, url);
} else {
item->setStatus(PhotoItemWidget::Status::Error, message);
}
client->deleteLater();
pumpQueue(); // free the slot for the next photo
refreshPosting();
});
client->upload(prepared.filePath, QFileInfo(prepared.filePath).fileName(),
caption, item->date());
});
watcher->setFuture(QtConcurrent::run([source, cacheDir, slug]() {
return ImagePrep::prepare(source, cacheDir, slug);
}));
}
void MainWindow::refreshPosting()
{
int pending = 0;
int inFlight = 0;
int succeeded = 0;
int failed = 0;
for (PhotoItemWidget *item : std::as_const(m_items)) {
switch (item->status()) {
case PhotoItemWidget::Status::Pending:
++pending;
break;
case PhotoItemWidget::Status::Preparing:
case PhotoItemWidget::Status::Uploading:
++inFlight;
break;
case PhotoItemWidget::Status::Success:
++succeeded;
break;
case PhotoItemWidget::Status::Error:
++failed;
++pending; // a failure is re-postable
break;
}
}
const bool posting = inFlight > 0;
m_postButton->setEnabled(!m_items.isEmpty() && !posting && pending > 0);
m_postButton->setText(posting ? QStringLiteral("Posting… (%1/%2)").arg(succeeded).arg(m_items.size())
: pending == 1 ? QStringLiteral("Post 1 photo")
: pending > 1 ? QStringLiteral("Post %1 photos").arg(pending)
: QStringLiteral("All posted"));
if (!posting && (succeeded + failed) > 0) {
QString summary = QStringLiteral("%1 posted").arg(succeeded);
if (failed > 0)
summary += QStringLiteral(", %1 failed").arg(failed);
m_postSummary->setText(summary);
}
}
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <QList>
#include <QMainWindow>
#include "settings.h"
class QDateEdit;
class QLabel;
class QLineEdit;
class QPushButton;
class QSpinBox;
class QStackedWidget;
class QVBoxLayout;
class PhotoItemWidget;
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
private:
QWidget *buildSettingsPage();
QWidget *buildPostPage();
void showSettings();
void showPost();
void saveAndTest();
void chooseImages();
void addItem(const QString &path);
void removeItem(PhotoItemWidget *item);
void clearItems();
void submit();
void pumpQueue(); // starts queued items up to the concurrency cap
void startItem(PhotoItemWidget *item);
int inFlightCount() const; // items currently preparing or uploading
void refreshPosting(); // updates the Post button + summary from item states
Settings m_settings;
QStackedWidget *m_stack = nullptr;
// Settings page
QLineEdit *m_baseUrl = nullptr;
QLineEdit *m_username = nullptr;
QLineEdit *m_password = nullptr;
QSpinBox *m_concurrency = nullptr;
QPushButton *m_saveButton = nullptr;
QPushButton *m_settingsBack = nullptr;
QLabel *m_settingsStatus = nullptr;
// Post page
QVBoxLayout *m_itemsLayout = nullptr; // holds the PhotoItemWidgets
QList<PhotoItemWidget *> m_items;
QLabel *m_emptyHint = nullptr;
QPushButton *m_postButton = nullptr;
QLabel *m_postSummary = nullptr;
QLabel *m_lastPost = nullptr;
// Bulk-post state: items waiting for a free upload slot, plus the batch's
// credentials captured once at submit time.
QList<PhotoItemWidget *> m_queue;
QString m_batchBaseUrl;
QString m_batchUsername;
QString m_batchPassword;
QString m_batchCacheDir;
};
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Photog
GenericName=Photo Uploader
Comment=Upload photos to your gallery
Exec=photog
Icon=photog
Terminal=false
Categories=Graphics;Photography;Network;
+101
View File
@@ -0,0 +1,101 @@
#include "photoitemwidget.h"
#include <QDate>
#include <QDateEdit>
#include <QHBoxLayout>
#include <QImageReader>
#include <QLabel>
#include <QLineEdit>
#include <QPixmap>
#include <QPushButton>
#include <QVBoxLayout>
namespace {
constexpr int kThumbSize = 72;
}
PhotoItemWidget::PhotoItemWidget(QString sourcePath, QWidget *parent)
: QWidget(parent)
, m_sourcePath(std::move(sourcePath))
{
auto *row = new QHBoxLayout(this);
row->setContentsMargins(0, 4, 0, 4);
m_thumb = new QLabel;
m_thumb->setFixedSize(kThumbSize, kThumbSize);
m_thumb->setAlignment(Qt::AlignCenter);
m_thumb->setFrameShape(QFrame::Box);
{
QImageReader reader(m_sourcePath);
reader.setAutoTransform(true);
const QImage image = reader.read();
if (!image.isNull())
m_thumb->setPixmap(QPixmap::fromImage(image).scaled(
kThumbSize, kThumbSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation));
else
m_thumb->setText(QStringLiteral("?"));
}
row->addWidget(m_thumb);
auto *fields = new QVBoxLayout;
m_caption = new QLineEdit;
m_caption->setPlaceholderText(QStringLiteral("Caption (defaults to the filename)"));
fields->addWidget(m_caption);
m_date = new QDateEdit(QDate::currentDate());
m_date->setCalendarPopup(true);
m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
fields->addWidget(m_date);
m_statusLabel = new QLabel;
m_statusLabel->setOpenExternalLinks(true);
m_statusLabel->setWordWrap(true);
fields->addWidget(m_statusLabel);
row->addLayout(fields, 1);
m_remove = new QPushButton(QStringLiteral("✕"));
m_remove->setFlat(true);
m_remove->setFixedWidth(32);
connect(m_remove, &QPushButton::clicked, this, [this]() { emit removeRequested(this); });
row->addWidget(m_remove, 0, Qt::AlignTop);
}
QString PhotoItemWidget::caption() const
{
return m_caption->text();
}
QString PhotoItemWidget::date() const
{
return m_date->date().toString(QStringLiteral("yyyy-MM-dd"));
}
void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url)
{
m_status = status;
// Inputs are frozen once the photo is in flight; the remove button stays
// usable except mid-transfer.
const bool editable = status == Status::Pending || status == Status::Error;
m_caption->setEnabled(editable);
m_date->setEnabled(editable);
m_remove->setEnabled(status != Status::Preparing && status != Status::Uploading);
switch (status) {
case Status::Pending:
m_statusLabel->clear();
break;
case Status::Preparing:
m_statusLabel->setText(QStringLiteral("Preparing…"));
break;
case Status::Uploading:
m_statusLabel->setText(QStringLiteral("Uploading…"));
break;
case Status::Success:
m_statusLabel->setText(QStringLiteral("Posted — <a href=\"%1\">View</a>").arg(url));
break;
case Status::Error:
m_statusLabel->setText(QStringLiteral("<span style=\"color:#fb4934\">%1</span>").arg(message));
break;
}
}
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <QString>
#include <QWidget>
class QDateEdit;
class QLabel;
class QLineEdit;
class QPushButton;
// One photo in the batch: thumbnail, caption, date, and its own upload status.
// A single-photo post is just a batch of one, so the window runs one code path.
class PhotoItemWidget : public QWidget
{
Q_OBJECT
public:
enum class Status { Pending, Preparing, Uploading, Success, Error };
explicit PhotoItemWidget(QString sourcePath, QWidget *parent = nullptr);
QString sourcePath() const { return m_sourcePath; }
QString caption() const;
QString date() const; // yyyy-MM-dd
Status status() const { return m_status; }
void setStatus(Status status, const QString &message = {}, const QString &url = {});
signals:
void removeRequested(PhotoItemWidget *self);
private:
QString m_sourcePath;
Status m_status = Status::Pending;
QLabel *m_thumb = nullptr;
QLineEdit *m_caption = nullptr;
QDateEdit *m_date = nullptr;
QLabel *m_statusLabel = nullptr;
QPushButton *m_remove = nullptr;
};
+126
View File
@@ -0,0 +1,126 @@
#include "settings.h"
#include <QEventLoop>
#include <QUrl>
#include <algorithm>
#include <qt6keychain/keychain.h>
namespace {
const QLatin1String kService("photogallery-uploader");
const QLatin1String kPasswordKey("password");
} // namespace
Settings::Settings()
: m_settings(QStringLiteral("inflo.ws"), QStringLiteral("photogallery-uploader"))
{
}
QString Settings::baseUrl() const
{
return m_settings.value(QStringLiteral("baseUrl")).toString();
}
void Settings::setBaseUrl(const QString &value)
{
m_settings.setValue(QStringLiteral("baseUrl"), normalizeBaseUrl(value));
}
QString Settings::username() const
{
return m_settings.value(QStringLiteral("username")).toString();
}
void Settings::setUsername(const QString &value)
{
m_settings.setValue(QStringLiteral("username"), value.trimmed());
}
int Settings::concurrency() const
{
const int v = m_settings.value(QStringLiteral("concurrency"), kDefaultConcurrency).toInt();
return std::clamp(v, 1, kMaxConcurrency);
}
void Settings::setConcurrency(int value)
{
m_settings.setValue(QStringLiteral("concurrency"), std::clamp(value, 1, kMaxConcurrency));
}
QString Settings::readPassword() const
{
QKeychain::ReadPasswordJob job(kService);
job.setAutoDelete(false);
job.setKey(kPasswordKey);
QEventLoop loop;
QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit);
job.start();
loop.exec();
if (job.error() != QKeychain::NoError)
return QString();
return job.textData();
}
void Settings::writePassword(const QString &value) const
{
QKeychain::WritePasswordJob job(kService);
job.setAutoDelete(false);
job.setKey(kPasswordKey);
job.setTextData(value);
QEventLoop loop;
QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit);
job.start();
loop.exec();
}
bool Settings::isConfigured() const
{
return !baseUrl().isEmpty() && !username().isEmpty() && !readPassword().isEmpty();
}
Settings::LastPost Settings::lastPost() const
{
LastPost p;
p.slug = m_settings.value(QStringLiteral("last/slug")).toString();
p.url = m_settings.value(QStringLiteral("last/url")).toString();
p.caption = m_settings.value(QStringLiteral("last/caption")).toString();
p.postedAt = m_settings.value(QStringLiteral("last/postedAt")).toDateTime();
return p;
}
void Settings::setLastPost(const LastPost &value)
{
m_settings.setValue(QStringLiteral("last/slug"), value.slug);
m_settings.setValue(QStringLiteral("last/url"), value.url);
m_settings.setValue(QStringLiteral("last/caption"), value.caption);
m_settings.setValue(QStringLiteral("last/postedAt"), value.postedAt);
}
QString Settings::validateBaseUrl(const QString &raw)
{
const QString trimmed = raw.trimmed();
if (trimmed.isEmpty())
return QStringLiteral("Base URL is required");
const QUrl url(trimmed);
if (!url.isValid() || url.host().isEmpty())
return QStringLiteral("Not a valid URL");
if (url.scheme() != QStringLiteral("https"))
return QStringLiteral("Must be https — plain http sends your password in the clear");
return QString();
}
QString Settings::normalizeBaseUrl(const QString &raw)
{
QString v = raw.trimmed();
while (v.endsWith(QLatin1Char('/')))
v.chop(1);
if (v.endsWith(QStringLiteral("/upload"), Qt::CaseInsensitive))
v.chop(QStringLiteral("/upload").size());
while (v.endsWith(QLatin1Char('/')))
v.chop(1);
return v;
}
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include <QDateTime>
#include <QSettings>
#include <QString>
// Persists the non-secret settings (base URL, username, last post) in a
// QSettings INI under ~/.config, and the password in the system keychain
// (libsecret / kwallet via QtKeychain). The password never lands on disk in the
// clear, mirroring the Android app's Keystore sealing.
class Settings
{
public:
struct LastPost {
QString slug;
QString url;
QString caption;
QDateTime postedAt;
bool valid() const { return !slug.isEmpty(); }
};
Settings();
QString baseUrl() const;
void setBaseUrl(const QString &value);
QString username() const;
void setUsername(const QString &value);
// Max photos uploaded at once in a bulk post, clamped to [1, kMaxConcurrency].
static constexpr int kMaxConcurrency = 8;
static constexpr int kDefaultConcurrency = 3;
int concurrency() const;
void setConcurrency(int value);
// Keychain access is synchronous here (a local event loop): the app does
// nothing until the credential is in hand, and there is no background retry
// worker as on Android that would need it off the UI thread.
QString readPassword() const;
void writePassword(const QString &value) const;
bool isConfigured() const;
LastPost lastPost() const;
void setLastPost(const LastPost &value);
// Returns an error message, or an empty string when the URL is usable.
// https is required: Basic auth would otherwise send the password in the
// clear.
static QString validateBaseUrl(const QString &raw);
// Trims trailing slashes and a trailing "/upload" (the endpoint path a user
// pasting the upload URL would leave on — the client appends it itself).
static QString normalizeBaseUrl(const QString &raw);
private:
mutable QSettings m_settings;
};
+126
View File
@@ -0,0 +1,126 @@
#include "uploadclient.h"
#include <QFile>
#include <QFileInfo>
#include <QHttpMultiPart>
#include <QHttpPart>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
UploadClient::UploadClient(QString baseUrl, QString username, QString password, QObject *parent)
: QObject(parent)
, m_base(std::move(baseUrl))
, m_username(std::move(username))
, m_password(std::move(password))
, m_net(new QNetworkAccessManager(this))
{
while (m_base.endsWith(QLatin1Char('/')))
m_base.chop(1);
}
QByteArray UploadClient::authHeader() const
{
const QByteArray raw = (m_username + QLatin1Char(':') + m_password).toUtf8();
return "Basic " + raw.toBase64();
}
void UploadClient::upload(const QString &filePath, const QString &fileName,
const QString &caption, const QString &date)
{
auto *file = new QFile(filePath);
if (!file->open(QIODevice::ReadOnly)) {
delete file;
emit uploadFinished(Result::Rejected, {}, {}, QStringLiteral("Could not read the prepared image"));
return;
}
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
const QByteArray contentType =
fileName.endsWith(QStringLiteral(".png"), Qt::CaseInsensitive) ? "image/png" : "image/jpeg";
QHttpPart imagePart;
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(contentType));
imagePart.setHeader(QNetworkRequest::ContentDispositionHeader,
QVariant(QStringLiteral("form-data; name=\"image\"; filename=\"%1\"").arg(fileName)));
file->setParent(multiPart);
imagePart.setBodyDevice(file);
multiPart->append(imagePart);
const auto textPart = [](const QString &name, const QString &value) {
QHttpPart part;
part.setHeader(QNetworkRequest::ContentDispositionHeader,
QVariant(QStringLiteral("form-data; name=\"%1\"").arg(name)));
part.setBody(value.toUtf8());
return part;
};
multiPart->append(textPart(QStringLiteral("caption"), caption));
multiPart->append(textPart(QStringLiteral("date"), date));
QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload")));
request.setRawHeader("Authorization", authHeader());
QNetworkReply *reply = m_net->post(request, multiPart);
multiPart->setParent(reply);
const QString fallbackSlug = QFileInfo(fileName).completeBaseName();
connect(reply, &QNetworkReply::finished, this, [this, reply, fallbackSlug]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString body = QString::fromUtf8(reply->readAll()).trimmed();
if (reply->error() == QNetworkReply::NoError && status >= 200 && status < 300) {
// Server replies "uploaded slug.ext"; recover the slug.
QString name = body;
if (name.startsWith(QStringLiteral("uploaded ")))
name = name.mid(QStringLiteral("uploaded ").size()).trimmed();
const int dot = name.lastIndexOf(QLatin1Char('.'));
QString slug = dot > 0 ? name.left(dot) : name;
if (slug.isEmpty())
slug = fallbackSlug;
emit uploadFinished(Result::Success, slug, m_base + QStringLiteral("/photo/") + slug + QLatin1Char('/'), {});
return;
}
if (status == 401 || status == 403) {
emit uploadFinished(Result::AuthFailed, {}, {}, QStringLiteral("Wrong username or password"));
return;
}
if (status >= 500 && status < 600) {
emit uploadFinished(Result::Transient, {}, {}, QStringLiteral("Server error %1").arg(status));
return;
}
if (status == 0) {
// No HTTP response — a socket/TLS failure. Worth retrying.
emit uploadFinished(Result::Transient, {}, {}, reply->errorString());
return;
}
emit uploadFinished(Result::Rejected, {}, {}, body.isEmpty() ? QStringLiteral("Upload rejected") : body);
});
}
void UploadClient::testConnection()
{
QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload")));
request.setRawHeader("Authorization", authHeader());
QNetworkReply *reply = m_net->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() == QNetworkReply::NoError && status >= 200 && status < 300) {
emit testFinished(QString());
return;
}
if (status == 401 || status == 403) {
emit testFinished(QStringLiteral("Wrong username or password"));
return;
}
if (status == 0) {
emit testFinished(reply->errorString());
return;
}
emit testFinished(QStringLiteral("Server returned %1").arg(status));
});
}
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <QObject>
#include <QString>
class QNetworkAccessManager;
// Talks to the gallery's POST /upload endpoint, which sits behind nginx HTTP
// Basic auth. The credentials go out preemptively in the Authorization header
// on every request (never via QNetworkAccessManager's authenticationRequired
// signal, which only fires on a 401 and would replay a multipart body already
// streamed off disk).
//
// Asynchronous: results arrive via signals so the UI stays responsive during an
// upload over a slow link.
class UploadClient : public QObject
{
Q_OBJECT
public:
enum class Result {
Success, // uploaded; slug/url populated
AuthFailed, // wrong credentials — a retry will not help
Rejected, // server said no for a reason a retry won't change
Transient, // network trouble or a 5xx — worth retrying
};
UploadClient(QString baseUrl, QString username, QString password, QObject *parent = nullptr);
// The filename matters: server.go derives the slug from it, so re-sending
// the same name overwrites the same post instead of duplicating it.
void upload(const QString &filePath, const QString &fileName,
const QString &caption, const QString &date);
// Credential check via GET /upload (same Basic-auth gate the POST uses).
// /health is not usable: nginx only proxies location /upload.
void testConnection();
signals:
void uploadFinished(UploadClient::Result result, const QString &slug,
const QString &url, const QString &message);
// errorMessage is empty on success.
void testFinished(const QString &errorMessage);
private:
QByteArray authHeader() const;
QString m_base;
QString m_username;
QString m_password;
QNetworkAccessManager *m_net;
};
+1 -1
View File
@@ -22,7 +22,7 @@
meta = with pkgs.lib; {
description = "Minimal static photo gallery";
license = licenses.mit;
license = licenses.unlicense;
mainProgram = "photogallery";
platforms = platforms.linux;
};
+8
View File
@@ -817,6 +817,14 @@ func (g *Generator) copyStatic() error {
if err := os.WriteFile(dst, data, 0644); err != nil {
return err
}
// Also serve the favicon from the site root, where browsers and
// crawlers request /favicon.ico by default without a <link> tag.
if e.Name() == "favicon.ico" {
if err := os.WriteFile(filepath.Join(g.outputDir, e.Name()), data, 0644); err != nil {
return err
}
}
}
return nil
}
+7
View File
@@ -37,6 +37,13 @@ func buildSite(t *testing.T, output, defaultView string) {
}
}
func writeFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
}
func readPage(t *testing.T, output, rel string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(output, filepath.FromSlash(rel), "index.html"))
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+2 -1
View File
@@ -20,6 +20,7 @@ func main() {
serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text")
mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line")
pageSize := flag.Int("page-size", 60, "photos per page on the timeline and grid views (timeline packs whole days, so pages vary slightly around this)")
manage := flag.Bool("manage", false, "serve the /manage pages for editing and deleting published photos (off by default; put them behind auth in nginx)")
defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other one is published under /<name>/")
flag.Parse()
@@ -46,6 +47,6 @@ func main() {
defer w.Close()
go w.Watch()
s := NewServer(*port, *contentDir, *outputDir, *serve, g)
s := NewServer(*port, *contentDir, *outputDir, *serve, *manage, g)
log.Fatal(s.Listen())
}
+38 -8
View File
@@ -47,14 +47,7 @@ func newTestServer(t *testing.T) *Server {
g := NewGenerator(content, output, "https://photos.example.com",
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 60, viewTimeline)
return NewServer("0", content, output, false, g)
}
func writeFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
return NewServer("0", content, output, false, true, g)
}
func readMeta(t *testing.T, s *Server, slug string) PhotoMeta {
@@ -323,3 +316,40 @@ func TestDeleteRejectsBadInput(t *testing.T) {
})
}
}
func TestManageRoutesNeedTheFlag(t *testing.T) {
for _, manage := range []bool{false, true} {
s := newTestServer(t)
s.manage = manage
mux := s.routes()
want := http.StatusNotFound
if manage {
want = http.StatusOK
}
for _, path := range []string{"/manage", "/manage/edit/sunrise", "/manage/delete/sunrise"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != want {
t.Errorf("manage=%v: GET %s = %d, want %d", manage, path, w.Code, want)
}
}
// The delete POST only does something with the flag: without it the
// photo must survive, with it (no Origin header, as from curl) it goes.
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("POST", "/manage/delete/sunrise", nil))
_, err := os.Stat(filepath.Join(s.contentDir, "sunrise.jpg"))
present := err == nil
if present == manage {
t.Errorf("manage=%v: POST delete = %d, photo present afterwards = %v", manage, w.Code, present)
}
// /upload and /health are there regardless.
for _, path := range []string{"/upload", "/health"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != http.StatusOK {
t.Errorf("manage=%v: GET %s = %d, want 200", manage, path, w.Code)
}
}
}
}
+5 -3
View File
@@ -88,6 +88,8 @@ in {
description = "View published at the site root. The other one is published under /timeline/ or /grid/.";
};
manage.enable = lib.mkEnableOption "the /manage pages for editing and deleting published photos (served behind the same htpasswd as /upload)";
nginx = {
enable = lib.mkEnableOption "nginx virtual host for photogallery";
@@ -140,7 +142,7 @@ in {
User = "photogallery";
Group = "photogallery";
ExecStart = lib.escapeShellArgs [
ExecStart = lib.escapeShellArgs ([
"${pkg}/bin/photogallery"
"--content" cfg.contentDir
"--output" cfg.outputDir
@@ -155,7 +157,7 @@ in {
"--ml-family" cfg.mlFamily
"--page-size" (toString cfg.pageSize)
"--default-view" cfg.defaultView
];
] ++ lib.optional cfg.manage.enable "--manage");
Restart = "on-failure";
RestartSec = "5s";
@@ -192,7 +194,7 @@ in {
extraConfig = "client_max_body_size 20M;";
};
locations."/manage" = {
locations."/manage" = lib.mkIf cfg.manage.enable {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
basicAuth = "gallery";
basicAuthFile = cfg.nginx.htpasswdFile;
+14 -2
View File
@@ -18,31 +18,43 @@ type Server struct {
contentDir string
outputDir string
serve bool
manage bool
generator *Generator
}
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator) *Server {
func NewServer(port, contentDir, outputDir string, serve, manage bool, g *Generator) *Server {
return &Server{
port: port,
contentDir: contentDir,
outputDir: outputDir,
serve: serve,
manage: manage,
generator: g,
}
}
func (s *Server) Listen() error {
// routes builds the handler. The /manage pages are only registered with
// -manage, so a service without the flag has no edit or delete code path at
// all and answers 404 there.
func (s *Server) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /upload", s.handleUploadPage)
mux.HandleFunc("POST /upload", s.handleUpload)
if s.manage {
mux.HandleFunc("GET /manage", s.handleManagePage)
mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage)
mux.HandleFunc("POST /manage/edit/{slug}", s.handleEdit)
mux.HandleFunc("GET /manage/delete/{slug}", s.handleDeletePage)
mux.HandleFunc("POST /manage/delete/{slug}", s.handleDelete)
}
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
return mux
}
func (s *Server) Listen() error {
mux := s.routes()
if s.serve {
// Serve the generated site on the same port — for local development only.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+3
View File
@@ -6,6 +6,9 @@
<title>{{.Date}} · Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
+9
View File
@@ -6,6 +6,9 @@
<title>Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
@@ -29,6 +32,12 @@
</svg>
RSS feed
</a>
<a href="https://git.inflo.ws/aashiks/photog" class="rss-a" target="_blank" rel="noopener">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M5.7 4.3 2 8l3.7 3.7 1-1L4 8l2.7-2.7zM10.3 4.3l-1 1L12 8l-2.7 2.7 1 1L14 8z"/>
</svg>
Source
</a>
</div>
</div>
</header>
+3
View File
@@ -25,6 +25,9 @@
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
{{if .Newer}}<link rel="prefetch" href="/photo/{{.Newer.Slug}}/">
+9
View File
@@ -6,6 +6,9 @@
<title>Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
@@ -29,6 +32,12 @@
</svg>
RSS feed
</a>
<a href="https://git.inflo.ws/aashiks/photog" class="rss-a" target="_blank" rel="noopener">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M5.7 4.3 2 8l3.7 3.7 1-1L4 8l2.7-2.7zM10.3 4.3l-1 1L12 8l-2.7 2.7 1 1L14 8z"/>
</svg>
Source
</a>
</div>
</div>
</header>
+3
View File
@@ -6,6 +6,9 @@
<title>Upload — Photo Gallery</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>
.upload-page {
max-width: 520px;